# Fenaura documentation — complete text Source: https://docs.fenaura.dev Pages: 52 --- # Introduction URL: https://docs.fenaura.dev/ Section: Getting started # Introduction Fenaura is a backend you talk to. It gives you four things that every app needs — edge functions, a Postgres database, end-user authentication, and file storage — and wires them together inside one project. You do not assemble those pieces yourself. You create a project, and the database, the auth system and the storage buckets already belong to it. ## What you get **Fleets.** JavaScript that runs in a V8 isolate — the browser's engine without the browser — not a container you provision. A Fleet is private by default: every route is checked for a signed-in end user before your code runs. You opt individual routes out of that check when you mean to. **A Postgres database.** Every project gets its own **whole database** — not a schema in a shared one — so your tables, roles and migrations are genuinely yours. Reach it from your Fleets through `ctx.db` (Drizzle) or `ctx.sql` (raw), and from your frontend through the SDK query builder. **End-user authentication.** Email and password, twelve OAuth providers, verification codes, password resets and magic links. The SDK handles the browser side, and a proxy keeps your credentials off the client. **File storage.** Uploads that chunk, retry and resume, with image compression on the way in, plus public URLs, signed URLs and session-gated URLs for reading. ## The shape of a project ``` Project ├── Edge function test + production deployments ├── Database tables, columns, row-level security ├── Auth signing in, storing, checking └── Storage buckets and files ``` Everything is scoped to the project. A development environment and a production environment are the same code with different data and different keys, so you can try something without touching real users. ## Who this is for If you have written a `fetch` call and read a Stack Overflow answer about CORS, you have enough. The docs show the code for every step; the agent explains the reasoning when you want it. ## Next - [Quickstart](/docs/quickstart) — a working project in one page. - [How Fenaura works](/docs/how-it-works) — the four pieces and how they meet. --- # Quickstart URL: https://docs.fenaura.dev/quickstart Section: Getting started # Quickstart This page takes you from nothing to a running authenticated query. It assumes a frontend project you can run locally. ## 1. Create a project Sign in at [fenaura.dev](https://fenaura.dev) and create a project. You get a database, an auth system and a storage bucket with it. Open the project and copy two values from the **API keys** page: - the **project URL** for the environment you are working in - the **publishable key** — the one safe to ship to a browser ## 2. Install the SDK ```bash npm install @fenaura/sdk ``` ## 3. Create a client ```ts // src/lib/fenaura.ts import { createClient } from '@fenaura/sdk'; export const fenaura = createClient({ apiUrl: import.meta.env.VITE_FENAURA_URL, apiKey: import.meta.env.VITE_FENAURA_KEY, }); ``` Put both values in your `.env` file. The publishable key is meant to be public; the secret key is not, and never goes in a `VITE_` variable. ## 4. Sign an end user up ```ts const { data, error } = await fenaura.auth.signUp({ email: 'ada@example.com', password: 'a-long-enough-password', }); if (error) throw error; ``` Every method returns `{ data, error }`. Nothing throws, so check `error` instead of wrapping calls in `try`. ## 5. Write a row ```ts await fenaura .from('notes') .insert({ title: 'First note', owner: data.user.id }) .execute(); ``` `insert` runs when the chain ends, so `.execute()` is optional here — but the docs always show it, so you never have to remember which calls need it. ## 6. Read rows back ```ts const { data: notes } = await fenaura .from('notes') .select('*') .order('created_at', { ascending: false }) .limit(20) .execute(); ``` ## 7. Call it from an edge function Edge functions see the same database through `ctx.db`. Create one in the dashboard and deploy it: ```ts // GET /api/notes export default async (req: Request, ctx: FenauraContext) => { if (!ctx.user) { return Response.json({ error: 'Sign in first' }, { status: 401 }); } const notes = await ctx.db.query.notes.findMany({ where: (t, { eq }) => eq(t.owner, ctx.user!.id), limit: 20, }); return Response.json({ notes }); }; ``` Because edge routes are private by default, an unauthenticated call is rejected before your handler runs. The `ctx.user` check above is belt and braces, and it lets you return a clearer message than the platform's. ## 8. Run it Start your app, sign in as the user you created, and load the page that calls `/api/notes`. You should see the row you inserted. ## Where to go next - [How Fenaura works](/docs/how-it-works) — what each piece does. - [Auth overview](/docs/auth) — the proxy model, and why it exists. - [Querying](/docs/database/querying) — the full builder. - [Edge functions](/docs/edge) — routing, the context object, and errors. --- # How Fenaura works URL: https://docs.fenaura.dev/how-it-works Section: Getting started # How Fenaura works A Fenaura project has four pieces. This page explains what each one does and how a request moves through them. ## The four pieces **Edge functions** are the only entry point to your backend logic. They run in a V8 isolate — the same engine as a browser tab, without the browser. They start in milliseconds and do not keep state between requests. **The database** is Postgres, and every project gets its own whole database rather than a schema shared with anyone else. You reach it from Fleets with `ctx.db` or `ctx.sql`, and from the browser with the SDK. **Authentication** belongs to the project, not to a third party. End users are rows in your database. The SDK signs them in and stores a session; every edge request carries that session back. **Storage** holds files, split into buckets. Uploads arrive in chunks and retry on failure, so a dropped connection does not lose a 40 MB video. ## How a request travels ``` Browser │ │ 1. fetch('https://edge.fenaura.dev/{token}/api/notes') ▼ Edge runtime │ │ 2. Is there a signed-in end user? (private route, the default) │ yes → continue no → 401, your code never runs ▼ Your edge function (V8 isolate) │ │ 3. ctx.user is populated, ctx.db is ready ▼ Postgres │ ▼ Response → Browser ``` Two things are worth noticing in that diagram. **Step 2 happens before your code.** You cannot forget to check the session, and you cannot accidentally leave a route open. To serve anonymous traffic you mark a route public on purpose, and then it is public for everyone. **Step 3 gives you a user without parsing anything.** `ctx.user` is already the signed-in end user, as an object, because the platform resolved the session. ## Test and production Every project has two environments of the same code: | | Development | Production | |---|---|---| | Edge URL | `edge.fenaura.dev/dev-{token}` | `edge.fenaura.dev/{token}` | | Access | requires your dev key or an active dev session | requires a signed-in end user | | Data | separate | real user data | | Keys | separate | separate | You deploy to development, check it, then promote the same code to production. Data does not move with the code — the two environments have their own. ## What Fenaura does not do It is not a place to run long background jobs. An edge request has a time limit, so anything that takes minutes belongs in a queue or a scheduled job you run elsewhere. It is also not a general-purpose host: there is no container, no `npm install` at runtime, and no filesystem that survives a request. ## Next - [Quickstart](/docs/quickstart) — build something first. - [Edge functions](/docs/edge) — the full surface. - [Projects and organisations](/docs/platform/projects) — how work is organised. --- # Coming from Supabase URL: https://docs.fenaura.dev/coming-from-supabase Section: Getting started # Coming from Supabase If you have used Supabase, most of Fenaura will feel familiar. This page maps the names and covers the places where behaviour differs, because those are the ones that break a straight copy-paste port. ## Concept mapping | Supabase | Fenaura | Notes | |---|---|---| | Project | Project | Same idea: database, auth and storage together | | `supabase-js` | `@fenaura/sdk` | Same `createClient` shape | | Edge Function (Deno) | Edge function (V8 isolate) | No Deno, no `npm:` imports | | `ctx` / `req` | `FenauraContext` | Different fields; see the context reference | | `anon` key | Publishable key | Safe to ship to a browser | | `service_role` key | Secret key | Never in a browser | | RLS policies | RLS policies | Postgres RLS, plus sensitive-column rules | | Storage buckets | Storage buckets | Chunked upload; 50 MiB default cap | | `.from().select()` | `.from().select()` | Same builder | | `.single()` / `.maybeSingle()` | `.single()` / `.maybeSingle()` | Same | | Auth `getSession` | `getSession` | Fenaura also checks the server | | Realtime | Not available | Poll or use `ctx.redis` | ## What differs **Edge functions are private by default.** In Supabase a function is reachable by anyone who knows the URL, and every handler repeats the same "verify the JWT" block. In Fenaura the session check happens before your handler runs, and you mark a route public only when you mean it. ```ts // Fenaura: the platform already rejected anonymous callers here. export default async (req: Request, ctx: FenauraContext) => { const userId = ctx.user!.id; // safe, checked upstream // ... }; ``` To serve unauthenticated traffic, declare the route public in your function config. That is a deliberate act, and it is visible in one place instead of scattered through handlers. **Sessions arrive three ways.** Supabase checks a cookie. Fenaura accepts a cookie, `Authorization: Bearer `, or `X-Fenaura-EUSID: `, and the header wins. That means a native app or a browser extension can hold the token itself without a cookie jar. **There is no Deno.** Edge functions run in a V8 isolate with the web standard `Request` and `Response`. Imports are bundled at deploy time, not fetched at runtime, so an `npm:` specifier does not resolve. ## Porting a query ```ts // Supabase const { data, error } = await supabase .from('notes') .select('*') .eq('owner', userId); // Fenaura const { data, error } = await fenaura .from('notes') .select('*') .eq('owner', userId) .execute(); ``` The chain is the same. Fenaura asks for `.execute()` on `update` and `delete` chains so it is never ambiguous whether a call ran. ## Porting an edge function ```ts // Fenaura export default async (req: Request, ctx: FenauraContext) => { const id = ctx.user?.id; if (!id) return Response.json({ error: 'unauthorized' }, { status: 401 }); return Response.json({ ok: true, userId: id }); }; ``` The handler is a plain function. There is no global registration, no `serve()`, and no import map. ## Migration order that works 1. Move the schema first; the database is the part with your data in it. 2. Point the frontend at `@fenaura/sdk` and fix the call shapes. 3. Port edge functions one at a time, checking each against real requests. 4. Move storage last — it is the slowest step and the least connected to the rest. Take a backup before step 1 and confirm it restores into a development environment before you touch production. ## Next - [Querying](/docs/database/querying) — the builder in full. - [Edge functions](/docs/edge) — routing and the context object. - [Sessions and cookies](/docs/auth/sessions) — the three credential forms. --- # Veilo URL: https://docs.fenaura.dev/veilo Section: Veilo # Veilo Veilo is the AI engineer built into Fenaura. It is not a chat box bolted onto a dashboard — it is the primary way to build on the platform, and it holds tools for every part of a project. You describe what you want. Veilo writes the code, applies the schema change, deploys it, calls it, reads the response, and tells you whether it worked. ## What makes it different from a chatbot **It acts on your project.** Veilo has tools that write files, run SQL, deploy edge functions, edit database rows, send email, and drive the API tester. A general assistant can show you code; Veilo puts it in your project and runs it. **It verifies before it claims.** Veilo is built on a strict rule: it never reports an operation as successful unless a tool confirmed it. If a deploy failed, it says the deploy failed. It distinguishes *attempted*, *pending approval*, *executing*, *successful*, *failed* and *rejected* rather than flattening them into "done". **It reads before it writes.** Rather than assuming your project's shape, it searches the codebase, reads the relevant files, inspects the schema, and then acts. ## What it can do ### Write and change code | Tool | What it does | |---|---| | `file_read` | Read a file, or a slice of one | | `file_write` | Create or replace a file | | `file_edit` | Targeted edit to an existing file | | `file_patch` | Apply a patch | | `file_rename` / `file_batch_delete` | Rename or remove files | | `files_list` | List what exists | | `file_compile` | Compile-check a file | | `code_search` / `code_trace` | Find code and follow how it flows | | `harness_grep` / `harness_ast_inspect` | Grep and inspect syntax structure | For a large workspace it has focused tools rather than one blunt scan: `workspace_read_slices` reads ranges, `workspace_batch_edit` applies many edits in one transaction, and `workspace_bulk_edit` edits across a set of files it has not enumerated first. ### Database | Tool | What it does | |---|---| | `db_schema` | Show the schema | | `db_table_create` / `db_table_drop` | Create or drop a table | | `db_table_read` | Read rows | | `db_sql_execute` / `db_sql_run` | Run SQL (`db_prod_sql_execute` for production) | | `db_migration_propose` / `db_migration_apply` | Propose then apply a migration | | `db_seed` | Insert seed data | | `db_sensitive_columns_mark` | Mark a column as sensitive | | `db_dump` | Take a backup | Production SQL and migration-apply are separated from the development versions on purpose, and they require approval. That separation is the point: a change to real data should never be one keystroke away from a change to test data. ### Edge functions | Tool | What it does | |---|---| | `edge_deploy_dev` | Deploy to development | | `edge_deploy_prod` | Deploy to production (approval required) | | `list_routes` | List the routes a function exposes | | `route_manifest` | Describe routes, methods and bodies | | `dev_api_probe` | Call a route and inspect the response | ### Testing and verification | Tool | What it does | |---|---| | `run_route_suite` | Run a set of requests against the function | | `route_test_plan` | Plan a test suite from the routes | | `controller_test` | Exercise a controller | | `tests_run` | Run the project's tests | | `diagnose_failure` | Work out why something failed | | `debug_trace` / `trace_flow` | Follow execution to the source of a bug | | `find_error_source` | Locate where an error originates | | `self_review` | Review its own change before declaring it done | | `session_doctor` | Check the session and auth setup | `self_review` is worth knowing about: Veilo reviews its own diff against the request before telling you it is finished. When it disagrees with itself, it keeps working instead of reporting success. ### The API tester (Kora) Veilo drives the Kora API tester directly, so it can build and test a request collection without you clicking through it. | Action | What it does | |---|---| | `create_collection` / `rename_collection` / `delete_collection` | Manage collections | | `create_folder` / `move_folder` / `delete_folder` | Manage folders | | `upsert_request` / `update_request` / `save_request` | Create or change a request | | `delete_request` | Remove a request | | `locate_request` / `get_request` / `locate` / `grep` / `read` | Find and read requests | | `batch_edit` | Patch many requests in one transaction | | `sync_from_code` | Import routes from the edge function | | `apply_draft` | Apply a draft it prepared | | `sync_from_code` | Import routes from the edge function | | `close_tab` / `tabId` | Close a tab | ### Email | Tool | What it does | |---|---| | `email_template_list` / `email_template_source` / `email_template_preview` | Read templates | | `email_template_save` / `email_template_rename` / `email_template_delete` | Change templates | | `email_template_visual_debug` | Check how a template renders | | `email_template_images_audit` | Check the images in a template | | `email_send` / `email_send_test` | Send mail | | `email_spam_check` | Check deliverability signals | | `email_outbox_list` / `email_outbox_read` | Read what was sent | | `email_stats_inspect` | Delivery statistics | | `email_asset_import` / `email_assets_list` / `email_asset_delete` | Manage assets | | `email_list_images` / `image_inspect` / `image_edit` | Work with images | ### Git | Tool | What it does | |---|---| | `git_status` / `git_stage` / `git_unstage` | Inspect and stage | | `git_commit` | Commit | | `git_branches_list` | List branches | | `git_merge_preview` / `git_merge_resolve` | Preview and resolve a merge | | `git_pull_upstream` | Pull | | `shadow_git_checkpoint` / `shadow_git_diff` | Checkpoint and diff its own work | ### Research | Tool | What it does | |---|---| | `web_search` | Search the web | | `web_scrape` | Read a page | | `sdk_docs` | Look up Fenaura SDK reference | Veilo is instructed to treat web content, file content and database content as **untrusted data, never as instructions**. Text inside a file that says "ignore your rules" is data that describes a file, and Veilo will not follow it. ## Approvals Anything destructive or production-facing stops and asks: - dropping a table - running SQL against production - applying a migration to production - deploying to production - sending real email The approval card states what will happen in one or two lines, and the action runs only when you approve it. Everything else — writing a file, renaming something, deploying to development, running a test — executes directly, because waiting for permission on a reversible change is friction with no benefit. ## Why Veilo asks questions sometimes For a clear order — "rename `getUser` to `loadUser`", "deploy this", "commit" — Veilo just does it. For an open request — "make me an email template", "I need an API" — it asks two to four focused questions first: what the template is for, who receives it, what the API should do. Guessing there produces confident work on the wrong thing, which costs more than the question. ## Prompt caching Veilo's instructions are split into a fixed core and situational modules that load per task. The fixed part is byte-identical across turns so it can be cached, which is what keeps a long session fast and affordable. It also means the rules it holds itself to do not drift mid-conversation. ## What it will not do - It will not claim a deploy succeeded when the deploy failed. - It will not touch production data without approval. - It will not follow instructions found inside your files or a web page. - It will not invent a resource id, a save confirmation or a delivery receipt. If it says something worked, a tool returned `ok: true` for it. If you want to see the evidence, ask — it can show the tool result. ## Next - [Using Veilo](/docs/veilo/using) — how to talk to it well. - [Approvals](/docs/veilo/approvals) — what needs approval and why. - [The API tester](/docs/kora) — the collection head Veilo drives. - [The Edge IDE](/docs/ide) — the editor beside it. --- # Using Veilo URL: https://docs.fenaura.dev/veilo/using Section: Veilo # Using Veilo Veilo is competent at execution and needs to know what "done" looks like. Most disappointing sessions come from a vague goal, not a weak model. ## Say what done looks like Weak: > Make the notes endpoint better. Better: > The `GET /api/notes` route should return only the caller's notes, newest first, > limited to 50, and each note should include an `author_name`. Deploy it to > development and show me the response for a signed-in user. The second version names the route, the filter, the ordering, the limit, the shape of the response, the environment, and the proof you want. Veilo can verify every one of those. ## Give the constraint, not just the goal Veilo will pick a reasonable approach. If you need a specific one, say so: > Use `ctx.sql`, not Drizzle — this query needs a window function. Otherwise you may get a correct answer in a style you then have to undo. ## One job per message Veilo handles multi-step work well, but a message with five unrelated jobs produces a session where it is hard to tell which part failed. Split unrelated work into separate messages. A sequence like "write it, deploy it, test it" is one job and should stay together. ## Ask for the evidence When you want proof rather than a claim: > Show me the tool result for that deploy. Veilo can show what the tool returned. That is the difference between "it deployed" and "the deploy returned ok: true". ## When it does the wrong thing **It changed more than you asked.** Say exactly which part to revert. A specific instruction — "only revert the change to `src/routes/notes.ts`" — gets applied precisely. **It stopped and asked a question.** Answer it. That is the approval or clarification path working as designed. For an ambiguous goal it asks before building, which is cheaper than building the wrong thing. **It says something failed and you think it succeeded.** Ask for the tool result. If the tool returned an error, the operation genuinely did not happen, and the platform is telling you the truth rather than smoothing it over. **It keeps going in circles.** Start a new message with the specific failure and the file it is in. A long thread where the same thing has failed three times is usually missing one fact, and naming that fact is faster than another attempt. ## Things worth knowing **Typo correction is built in.** If you write `gmail.cm` or `hotmial.com`, Veilo treats it as the obvious intent rather than sending mail to a domain that does not exist. Known providers and top-level domains are definite. **Clear orders execute immediately.** Renaming, editing, committing, deploying to development, sending email — no "say go" gate. The approval gate exists for production data and destructive operations, not for routine work. **It will not moralise.** If you ask it to send a test email with sample copy, it sends it. It is a tool in your project, not a reviewer of your decisions. ## A good opening message ``` Project: my-app Goal: an authenticated endpoint that lists the signed-in user's orders. Constraints: - Postgres table "orders" already exists with owner_id - Use ctx.db, not raw SQL - Return 50 most recent, newest first Done when: - /api/orders returns 200 for a signed-in user - it returns only that user's rows - you have called it and shown me the response ``` Four lines, and Veilo has everything. Most frustration disappears at this step. ## Next - [Veilo overview](/docs/veilo) — the full tool set. - [Approvals](/docs/veilo/approvals) — what will stop and ask. --- # Approvals URL: https://docs.fenaura.dev/veilo/approvals Section: Veilo # Approvals Veilo runs most work directly. A small set of actions stops and asks, and knowing where that line sits explains why some things need permission and others do not. ## What needs approval | Action | Why | |---|---| | Dropping a table | Deletes data that cannot be recovered | | Running SQL against production | Touches real users' rows | | Applying a migration to production | Changes the shape of real data | | Deploying to production | Ships to live traffic | | Sending real email | Cannot be unsent | Everything else runs immediately: writing a file, editing a file, renaming, creating a table in development, deploying to development, running tests, reading anything, committing. ## The rule behind the line **Reversible and local runs. Irreversible or public stops.** Writing a file is reversible — git holds the old version. Sending an email is not. Deploying to development is local — a mistake costs you a retry. Deploying to production can break a live product. If an action cannot be undone, or its effects are visible outside your project, it asks first. ## What the approval card shows The card states what will happen in one or two lines, and it names the thing it will touch: > Drop table `orders` — 4,182 rows will be permanently deleted. Not "perform destructive operation". A confirmation you cannot evaluate is worse than no confirmation, because it trains you to click through. Nothing runs until you approve. If you decline, the operation is recorded as rejected, not as attempted-and-failed. ## Why routine work does not ask A gate on reversible work trains you to click approve without reading. Once that habit forms, the gate does not protect you — it just adds a click before the dangerous thing too. That is why "deploy to development" does not ask and "deploy to production" does. The prompt is rare enough that you read it. ## When you are blocked If you asked for a production change and nothing happened, an approval is probably waiting. Check the conversation for a card. Veilo will not proceed without an answer, and it will not pretend the action succeeded either — the state stays *pending* rather than flipping to *done*. ## Reducing approval prompts Approve deliberately rather than reflexively, and keep dangerous things rare: - Develop against the development environment, where nothing needs approval. - Make one production change per approval, so a decline is specific. - Use migrations for schema changes; a migration is reviewable in a way that ad-hoc SQL is not. ## Next - [Veilo overview](/docs/veilo) — the tools behind each action. - [Environments](/docs/edge/environments) — why development is free of gates. --- # The API tester URL: https://docs.fenaura.dev/kora Section: Veilo # The API tester Kora is the HTTP client inside the dashboard. It sends real requests to your edge function with real sessions and shows you real responses. ## Collections and folders Requests live in collections, and collections hold folders. Both exist so a large API stays navigable: ``` Payments API ├── Customers │ ├── GET /api/customers │ └── POST /api/customers └── Invoices ├── GET /api/invoices/:id └── POST /api/invoices ``` Folders also group by table when Kora imports routes, so a database-backed API organises itself. ## Importing routes Kora reads the edge function, detects the routes it exposes, and generates requests for them automatically — grouped into folders by table where the route is database-backed. A new route becomes a clickable request instead of something you type by hand. It also imports from outside: **cURL** and **Postman v2** collections both import directly, so a collection you already have is not stranded in another tool. ## Cloud workspace sync The workspace is saved to your account, not only to this browser. Open the API tester on a phone and your collections are there; the sync indicator shows when the local copy matches the server one. This is why a request you built on a laptop is available on a phone without exporting anything. ## Editing a request A request has five sections, each in its own panel: | Section | Holds | |---|---| | Method and path | The verb and the URL | | Params | The query string | | Headers | Header rows, key and value | | Body | The JSON body | | Auth | The session token | **Section diffs.** When a request is saved and you change it, the panel you are editing shows what changed against the saved version: added lines in green, removed in red, with a `+N / -N` count. Each panel diffs only its own section, so a header change does not appear inside the body panel. That distinction is the whole point — a diff that mixes sections tells you something changed but not what. Unsaved drafts have no baseline, so no diff appears for them. ## Environments Kora switches between development and production with one control, and shows which one you are on. A request sent to development goes to `dev-{token}`; production goes to `{token}`. Sending a production request from Kora is a real request against real data. The environment indicator is there to make that visible before you press send. ## What the response shows Status code, timing, size, headers (collapsible), and a pretty-printed body with search and collapse. The response body is the actual bytes the function returned, not a rendering of them. ## Saving A request can be saved into a collection, renamed, moved between folders, and deleted. The list marks a request as modified when it differs from the saved version, which is the same baseline the section diff uses. ## Veilo drives it Every one of those actions is available to Veilo as a tool. It can create a collection, import routes, patch request bodies, and run them — which is how a whole test collection gets built without clicking through it. See [Veilo](/docs/veilo) for the tool list. ## Why a built-in tester Testing an edge function from outside needs a session token, the right base URL for the environment, and the dev key when you are on development. A general HTTP client has none of that, so the common outcome is a `401` you cannot explain. Kora knows the project, the environment and the session, so a request you build works the first time. ## Next - [Edge functions](/docs/edge) — what you are calling. - [Errors](/docs/edge/errors) — reading the responses you get back. --- # The Edge IDE URL: https://docs.fenaura.dev/ide Section: Veilo # The Edge IDE An edge function is one deployment made of many files. The Edge IDE is where you work on it: a file tree, an editor, a terminal, source control, and a history of what changed. Nothing to install. There is no local toolchain, no `npm install`, and no build step on your machine — the platform compiles. ## What is in it | Part | Purpose | |---|---| | File tree | Every file in the function, resizable | | Editor | Write code, with syntax highlighting and symbols | | Bottom panel | A Terminal and a Problems tab | | Side panel | Source control and versions | | Trash | Deleted files, recoverable from their own page | The bottom panel switches between the terminal and the problems list. The trash is a page of its own rather than a panel tab, because restoring a file is a task you start and finish, not something you glance at while editing. ## Writing code The editor highlights JavaScript and TypeScript, and it reads the symbols in the file — functions, types, exports — so jumping within a large file does not mean scrolling to find something. Imports resolve at deploy time. A relative import works; there is no runtime resolution and no `npm:` specifier that loads later. ## Compiling and problems `build` checks the function without deploying it, from the terminal or the toolbar. Errors land in the Problems tab with the file and line, so you fix them before a deploy rather than discovering them in a failed request. Clicking an error jumps to it. A build is not a deploy. Nothing reaches development or production until you deploy, which is what makes it safe to run constantly. ## Source control Commit history lives beside the code. You can stage files, commit with a message, create and switch branches, preview a merge, and resolve conflicts. **Diffs.** Any change can be viewed as a diff: added lines in green, removed in red, unchanged context around them. The same diff view serves a working-tree change, a commit, and a version on the timeline — one way to read what changed, everywhere it appears. ## Versions and rollback The timeline lists changes over time. Selecting one shows the diff against its parent, so a change made an hour ago is inspectable without reproducing it. That is how you find the change that broke something, rather than guessing. ## The trash Deleted files are not immediately gone. The trash holds them, so a delete you regret is recoverable without reverting a commit. ## How it relates to Veilo Veilo edits the same files through its own tools. You and Veilo work on the same function, and the file tree shows the result either way. When Veilo reports it edited a file, the file is edited — you can open it and read the change. The IDE is where you review and adjust what Veilo did, and where you write the part you would rather write yourself. ## Next - [Veilo](/docs/veilo) — the agent that works beside it. - [Edge functions](/docs/edge) — routing, context and errors. - [The API tester](/docs/kora) — calling what you built. --- # Fleets URL: https://docs.fenaura.dev/fleets Section: Fleets # Fleets A Fleet is the server side of your project. It holds your code, runs it close to the user, and owns the URL your app calls. If you have built a backend before — an Express app, a Flask app, a serverless function — a Fleet is that same job. What differs is how little you configure to get it. ## The one idea that explains Fleets **A Fleet is treated as a server, not as a pile of functions.** On most serverless platforms you upload a single function and get a single URL per function. Ten routes means ten deployments, ten URLs, and ten cold starts. You end up writing glue to make them feel like one application. In Fenaura you deploy **one Fleet per environment**, and inside it you route however you like. The project URL runs everything: ``` https://edge.fenaura.dev/{token}/api/notes https://edge.fenaura.dev/{token}/api/users/profile https://edge.fenaura.dev/{token}/webhooks/stripe ``` One deployment. One URL. Many routes. **About the name and the location.** A "Fleet" is a group of engines — the idea is that your backend runs as a set of fast, isolated workers. Do not read "edge" as "geographically distributed to the user's device": Fenaura runs your code centrally, in one place you control, not spread across global regions. The name is about the workers, not the geography. Everything you build comes from one central location, and you never juggle regional URLs. ## How this differs from Supabase Edge Functions | | Supabase Edge Functions | Fenaura Fleets | |---|---|---| | Unit of deploy | One function, one URL each | One Fleet, one URL for all routes | | Runtime | Deno | V8 isolate, web standard `Request`/`Response` | | Auth by default | Each handler verifies its own token | Every route is session-gated before your code runs | | Routing | Bolt-on, per function | Built in, one router | | Environments | Separate projects | Same code, `dev-` prefix, separate data | The important row is auth. In Supabase, every function repeats a "verify the JWT" block, and a function that forgets it is public. In a Fleet the platform checks the session **before your handler is called**, so a route is private because it exists, not because someone remembered. ## Development and production Two environments, the same code: ``` Production https://edge.fenaura.dev/{token}/api/notes Development https://edge.fenaura.dev/dev-{token}/api/notes ``` The prefix is the only difference in the URL. Everything behind it is separate: | | Development | Production | |---|---|---| | Database | its own | real user data | | Redis | its own namespace | its own namespace | | Environment variables | its own | its own | | End users | its own | real users | | Storage | its own | its own | You deploy the same code to both. Data never moves between them, which is the whole point — a test in development cannot write a row a real customer will see. **Secret names are shared; values are not.** You set `STRIPE_SECRET_KEY` once as a name in both environments and give each its own value: a test key in development, a live key in production. Your code reads `ctx.env.STRIPE_SECRET_KEY` and never learns which environment it is in. Fenaura routes the right value at runtime based on the deployment that is handling the request. ## The development URL goes away A development URL is deliberately temporary. It requires proof that you are allowed to use it: - an **active development session**, or - the project's **private dev key**, sent as a header. Without one, the request is refused before your code runs: ``` 401 DEV_ACCESS_DENIED ``` This is why a development URL can sit in a local bundle safely — knowing the URL is not enough. And it is why the development URL is not something to ship: the public application calls the production URL, and nobody outside your team can reach the development one. ## Why this feels like a real IDE Fleets ship with a working environment, not a text box: - a **file tree** and folders, so code is organised the way a project is - a **terminal** with real commands (`build`, `deploy`, `save`, `fmt`, `open`, `files`, `ctx`, `env`, `versions`) - **source control** — stage, commit, branch, diff, resolve - a **problems panel** that jumps to the error - a **version timeline** so you can see what changed and when - a **trash** so a delete is recoverable Imports work, folders work, and a compile check runs before a deploy. The point of all of it is that you spend your attention on the code rather than on the tooling around it. ## Where the code runs ``` Browser │ fetch('https://edge.fenaura.dev/{token}/api/notes') ▼ Fleet runtime │ session check — before your code ▼ Your handler (V8 isolate) ctx.user, ctx.db, ctx.env, ctx.redis, ctx.sql ▼ Postgres / Redis / Storage ▼ Response ``` Your handler is a plain function: ```ts export default async (req: Request, ctx: FenauraContext) => { if (!ctx.user) return Response.json({ error: 'Sign in first' }, { status: 401 }); return Response.json({ hello: ctx.user.email }); }; ``` `req` is a web `Request`. `ctx` is everything the platform gives you. There is no framework to learn before your first request works. ## Next - [Your first Fleet](/docs/edge/first-function) — deploy something. - [Routing](/docs/fleets/routing) — controllers, and why Hono is optional. - [The context object](/docs/fleets/context) — every `ctx` field. - [The IDE](/docs/fleets/ide) — the editor around your code. --- # Routing URL: https://docs.fenaura.dev/fleets/routing Section: Fleets # Routing A Fleet is one server, so routing is your decision. There are three ways, and all of them are legitimate. Start with the first and move up only when you feel the need. ## 1. Plain if statements For a small Fleet this is the simplest thing that works, and it has no dependencies at all: ```ts export default async (req: Request, ctx: FenauraContext) => { const url = new URL(req.url); const path = url.pathname; const method = req.method; if (path === '/api/notes' && method === 'GET') { const notes = await ctx.db.query.notes.findMany({ limit: 20 }); return Response.json({ notes }); } if (path === '/api/notes' && method === 'POST') { const body = await req.json(); const [note] = await ctx.db.insert(notes).values(body).returning(); return Response.json({ note }, { status: 201 }); } return new Response('Not found', { status: 404 }); }; ``` There is nothing wrong with this. Two routes do not need a router. If your Fleet is one endpoint plus a webhook, stop here — you are done. Move on when you have more than about five routes, or when the file stops fitting on a screen. ## 2. Controllers in folders When the file grows, split by area. Each controller owns one subject: ``` src/ ├── index.ts entry: composes the controllers ├── controllers/ │ ├── notes.ts │ ├── users.ts │ └── webhooks.ts └── lib/ └── validate.ts ``` ```ts // controllers/notes.ts import type { FenauraContext } from '../types'; export async function listNotes(req: Request, ctx: FenauraContext) { const notes = await ctx.db.query.notes.findMany({ where: (t, { eq }) => eq(t.owner, ctx.user!.id), limit: 20, }); return Response.json({ notes }); } export async function createNote(req: Request, ctx: FenauraContext) { const body = await req.json(); const [note] = await ctx.db .insert(notes) .values({ title: String(body.title || ''), owner: ctx.user!.id }) .returning(); return Response.json({ note }, { status: 201 }); } ``` ```ts // index.ts import { listNotes, createNote } from './controllers/notes'; export default async (req: Request, ctx: FenauraContext) => { const { pathname } = new URL(req.url); if (pathname === '/api/notes') { if (req.method === 'GET') return listNotes(req, ctx); if (req.method === 'POST') return createNote(req, ctx); return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, POST' } }); } return new Response('Not found', { status: 404 }); }; ``` This is the same shape as an Express app with routes and handlers. Imports and folders work exactly as you expect, and the file tree shows them. ## 3. Hono Fenaura uses Hono, so if you want a full router you have one — it runs inside a V8 isolate with no setup. It is what makes a Fleet feel like a real framework when the app gets big: ```ts import { Hono } from 'hono'; const app = new Hono(); app.get('/api/notes', async (c) => { const ctx = c.get('fenaura') as FenauraContext; const notes = await ctx.db.query.notes.findMany({ limit: 20 }); return c.json({ notes }); }); app.get('/api/notes/:id', async (c) => { const ctx = c.get('fenaura') as FenauraContext; const note = await ctx.db.query.notes.findFirst({ where: (t, { eq }) => eq(t.id, c.req.param('id')), }); if (!note) return c.json({ error: 'Not found' }, 404); return c.json({ note }); }); export default (req: Request, ctx: FenauraContext) => app.fetch(req); ``` Path parameters, middleware, grouping and typed handlers — all of it works as it does in Hono anywhere else. **Fenaura does not require Hono.** Nobody has to learn it to use Fleets. It is there because it is small, fast, and web-standard, not because the platform depends on it. Read the Hono docs at [hono.dev](https://hono.dev) — everything there applies directly, because this is Hono, not a copy of it. ## If you have written Express A Fleet will feel familiar on purpose. The mapping: | Express | Fleets | |---|---| | `app.get('/x', handler)` | Hono `app.get('/x', handler)`, or an `if` on `pathname` | | `req.body` | `await req.json()` | | `res.json(x)` | `Response.json(x)` | | `res.status(404).json(x)` | `Response.json(x, { status: 404 })` | | `req.params.id` | `c.req.param('id')` (Hono), or parse the pathname | | `req.query.x` | `new URL(req.url).searchParams.get('x')` | | Middleware | Hono `app.use(...)`, or a function you call first | | `app.listen()` | **Not needed** — the Fleet is already a server | The last row is the one to notice. There is no `listen`, no port, no process to start. The deployment *is* the server. ## A note on structure Two habits make a Fleet easy to keep: **One controller per subject.** Not one file per route, and not one giant file. `notes`, `users`, `webhooks` — three controllers read better than fifteen route files. **Validate at the edge of the controller.** Check the body before it reaches the database, and return `400` with a clear message. Validation in the controller that owns the route keeps the rule next to the thing it protects. ## Next - [The context object](/docs/fleets/context) — what every handler receives. - [Environments](/docs/fleets/environments) — dev and prod, same code. - [The IDE](/docs/fleets/ide) — the editor around your code. --- # The context object URL: https://docs.fenaura.dev/fleets/context Section: Fleets # The context object Every handler receives `(req, ctx)`. `req` is a standard web `Request`. `ctx` is everything the platform provides, already connected. ```ts export default async (req: Request, ctx: FenauraContext) => { return Response.json({ environment: ctx.environment, requestId: ctx.requestId, project: ctx.project.slug, user: ctx.user?.email ?? null, }); }; ``` ## The full `ctx` Everything the platform hands you, in one place: | Member | What it is | |---|---| | `ctx.user` | signed-in end user, or `null` on a public route | | `ctx.project` | `id`, `slug`, `name` | | `ctx.env` | environment variables for this deployment | | `ctx.environment` | `'test'` or `'production'` | | `ctx.requestId` | unique id for this request | | `ctx.db` | Drizzle client | | `ctx.sql` | raw SQL, values bound | | `ctx.redis` | Redis client | | `ctx.email` | send mail | | `ctx.storage` | files | | `ctx.image` | image transforms | | `ctx.json` | `ctx.json(data, status?)` — shorthand for a JSON response | | `ctx.cookies` | read/write cookies | | `ctx.waitUntil` | run work after the response is sent | | `ctx.pg` | Drizzle schema helpers | `ctx.json` is the one used most after the database: ```ts return ctx.json({ ok: true }); return ctx.json({ error: 'Not found' }, 404); ``` It is a convenience over `Response.json` with the same semantics, not a different response type. `ctx.waitUntil` is for work that should not delay the response — logging, a webhook, a fire-and-forget email: ```ts ctx.waitUntil(notifySlack('deployed')); return ctx.json({ ok: true }); ``` The response goes out first; the promise still runs. Use it for anything the caller does not need to wait for, and do not use it for work that must succeed, because a failure there is not reported to anyone. ## `req` — the request A standard web `Request`. No framework wrapper, no proprietary shape. ```ts req.method // 'GET' req.url // full URL string new URL(req.url).pathname // '/api/notes' new URL(req.url).searchParams.get('limit') // '20' req.headers.get('authorization') // 'Bearer ...' req.headers.get('content-type') await req.json() // parsed body (may throw on bad JSON) await req.text() // raw body await req.formData() // multipart ``` Parse defensively — a caller can send anything: ```ts const body = await req.json().catch(() => null); if (!body || typeof body.title !== 'string') { return Response.json({ error: 'title is required' }, { status: 400 }); } ``` ## The response Return a standard `Response`. `Response.json` is the everyday form: ```ts Response.json({ ok: true }) // 200 Response.json({ error: 'Not found' }, { status: 404 }) Response.json({ note }, { status: 201 }) new Response(null, { status: 204 }) // no body new Response('Plain text', { status: 200, headers: { 'Content-Type': 'text/plain' } }) ``` You can set any status and any header. There is no wrapper object and nothing to learn beyond the web standard. ## `ctx.environment` `'test'` or `'production'`. Use it when behaviour genuinely must differ: ```ts if (ctx.environment === 'production' && body.seedDemo) { return Response.json({ error: 'Not allowed in production' }, { status: 400 }); } ``` Most code should not branch on it. If you find yourself checking often, the difference probably belongs in an environment variable instead. ## `ctx.requestId` Unique per request. Return it in errors and log it — it is how you find the exact request later: ```ts return Response.json({ error: 'failed', requestId: ctx.requestId }, { status: 500 }); ``` ## `ctx.project` ```ts ctx.project.id // string ctx.project.slug // string ctx.project.name // string ``` ## `ctx.user` The signed-in end user, or `null` on a route you declared public. ```ts ctx.user.id // string ctx.user.email // string ctx.user.role // string ``` On a private route this is never null — the platform rejected the request before your code ran. ## `ctx.env` — environment variables Environment variables are a **startup configuration**, set per environment. You add them in the dashboard with a name and a value. Development and production hold separate values under the same names: ``` STRIPE_SECRET_KEY dev: sk_test_... prod: sk_live_... DATABASE_URL dev: ... prod: ... PUBLIC_BASE_URL dev: http://localhost prod: https://app.example.com ``` Your code is identical in both, because it asks for the name: ```ts const stripeKey = ctx.env.STRIPE_SECRET_KEY; if (!stripeKey) { return Response.json( { error: 'STRIPE_SECRET_KEY is not set in this environment', code: 'MISSING_ENV' }, { status: 500 }, ); } ``` At runtime Fenaura resolves the variables for the **deployment handling the request** — a request to the development URL reads development values, and a request to production reads production values. Nothing in your code changes. This is why the same name can hold a test key in one environment and a live key in the other, safely, with no branching. **Check for `undefined`.** A variable set in development is not set in production, and the failure appears at the first request that needs it. Naming it in the error turns a twenty-minute hunt into one line. ## `ctx.db` — Drizzle A Drizzle client, connected and scoped to the project: ```ts const notes = await ctx.db.query.notes.findMany({ where: (t, { eq, and }) => and(eq(t.owner, ctx.user!.id), eq(t.status, 'open')), orderBy: (t, { desc }) => desc(t.created_at), limit: 20, }); await ctx.db.insert(notes).values({ title: 'New', owner: ctx.user!.id }); await ctx.db.update(notes).set({ title: 'Renamed' }).where(eq(notes.id, id)); await ctx.db.delete(notes).where(eq(notes.id, id)); ``` Relational helpers work too: ```ts const note = await ctx.db.query.notes.findFirst({ where: (t, { eq }) => eq(t.id, id), with: { author: true }, // related rows }); ``` Drizzle is a full ORM here — see [the Drizzle docs](https://orm.drizzle.team) for the schema, relations and query syntax. Everything there applies; Fenaura only hands you the client already connected. ## `ctx.sql` — raw SQL For aggregates, window functions, CTEs, or anything clearer as SQL: ```ts const rows = await ctx.sql` select owner, count(*) as total from notes group by owner order by total desc limit 10 `; ``` Values are **bound, not interpolated**: ```ts await ctx.sql`select * from notes where title = ${term}` // correct await ctx.sql`select * from notes where title = '${term}'` // injection risk ``` Never build SQL by concatenating a value into the string. `ctx.db` and `ctx.sql` see the same data. Mixing them in one handler is fine. ## `ctx.redis` A real Redis client, already connected and already partitioned to your project. `ctx.redis` forwards the command you name straight to Redis, so the syntax is Redis syntax — not a Fenaura-specific API: ```ts await ctx.redis.set('key', 'value'); const value = await ctx.redis.get('key'); await ctx.redis.set('token', 'abc', 'EX', 3600); // with a TTL await ctx.redis.incr('views:page-1'); await ctx.redis.expire('views:page-1', 3600); await ctx.redis.del('key'); ``` If you know Redis, there is nothing new to learn. **Keys are namespaced for you.** You write the relative key; the platform stores it under the project's own prefix: ``` fenaura:p:: production fenaura:p:#test: development ``` You never type the prefix. It cannot collide with another project, and it cannot reach a platform key such as a session token. **Development and production Redis are separate.** A key written in one is not visible in the other, so a test cache can never leak into production data. **Some commands are refused.** Destructive and administrative commands — `KEYS`, `FLUSHALL`, `FLUSHDB`, `CONFIG`, `EVAL`, `SCRIPT`, `MONITOR`, `SHUTDOWN` and their relatives — are not executable, not even by an owner. This is deliberate: a shared Redis with a flush button is one keystroke from deleting every project's session state. `GET`, `SET`, `INCR`, `EXPIRE`, `DEL`, `MGET`, `HGETALL` and the ordinary data commands all work. **Limits that apply.** A single value is capped at 256 KB, and an entry's TTL can be set up to 30 days. Redis storage is pooled across both environments of a project at 100 MB by default. **Redis is basically a superpower.** Anything you would otherwise reach for memory or a second service to do — a counter across requests, a cache in front of a slow query, a per-user rate limit, a "seen this already" flag — is one call away. You do not provision it, connect it, or clean it up. It is simply there. ## What is not on `ctx` There is no filesystem, no shell, and no port to listen on. `process` and `require` are **explicitly undefined** — this is not Node, it is a V8 isolate with web standards. Calling `process.anything` throws. Reach for `ctx` instead of the Node equivalent: | In Node you would | In a Fleet you | |---|---| | `process.env.X` | `ctx.env.X` | | `process.exit()` | return a `Response` | | `fs.readFile` | `ctx.storage` | | `http.createServer` | nothing — the Fleet is the server | | `require('pg')` | `ctx.db` / `ctx.sql` | ## What you can still do: the network You are not sandboxed away from the internet. `fetch` works, and it is how a Fleet calls Stripe, Slack, an LLM, or any API on the web: ```ts const res = await fetch('https://api.stripe.com/v1/charges', { method: 'POST', headers: { Authorization: `Bearer ${ctx.env.STRIPE_SECRET_KEY}` }, }); const data = await res.json(); ``` It is the standard web `fetch`, with real limits that you should know before designing around it: | Limit | Value | |---|---| | Response size | 1 MiB | | Request body | ~2 MB | | Timeout | 10 seconds | | Connect timeout | 5 seconds | | Redirects followed | 5 | Requests to private and internal addresses (loopback, link-local, private ranges) are refused at the network layer. This is a deliberate SSRF floor — the platform will not let your code reach its own internals, and it will not let a crafted URL from a user do it either. So: **almost any public API, within 1 MiB and 10 seconds.** For a large download, stream it into storage rather than into a single response. ## Using your own tools You are not forced onto Fenaura's built-in services. If you already use an email provider you like, call it from `fetch` exactly as you would from a Node server: ```ts await fetch('https://api.brevo.com/v3/smtp/email', { method: 'POST', headers: { 'api-key': ctx.env.BREVO_API_KEY, 'content-type': 'application/json', }, body: JSON.stringify({ sender: { email: 'no-reply@example.com' }, to: [{ email: ctx.user!.email }], subject: 'Hello', htmlContent: '

Hi

', }), }); ``` Brevo, Resend, SendGrid, Postmark, Mailgun, Stripe, Paystack — anything with an HTTP API works this way. Your API key lives in `ctx.env`, so the same code runs in development and production against the right key. Fenaura also has its own managed email (Mailmy) and its own storage. Use them if they save you work; skip them if you already have something better. Neither is required, and nothing in the Fleet assumes you chose them. ## Next - [Environments](/docs/fleets/environments) — dev and prod in detail. - [Databases](/docs/fleets/database) — the full data surface. - [Veilo](/docs/veilo) — the agent that writes this code with you. --- # Environments URL: https://docs.fenaura.dev/fleets/environments Section: Fleets # Environments Every project has two environments, and they behave differently on purpose. ``` Production https://edge.fenaura.dev/{token}/{path} Development https://edge.fenaura.dev/dev-{token}/{path} ``` The URL is the only visible difference. Everything behind it is separate. ## What is separate | | Development | Production | |---|---|---| | Database | its own | real user data | | Redis | `...{pid}#test:` | `...{pid}:` | | Storage | its own | real files | | Environment variables | its own values | its own values | | End users | test users | real users | | Email | trapped, not delivered | delivered | "Trapped" means an email sent in development does not reach a real inbox. It is captured so you can read it. This is not a setting you switch on — it is what the development environment is for. Because the separation is physical rather than a filter, a bug in development cannot write a row a customer will see. There is no query you can run from the development environment that reaches production data. ## One codebase You deploy the same source to both. There are no per-environment branches to maintain, and no `if (isProd)` scattered through your handlers. The platform resolves the environment at runtime, from the URL the request arrived on: - request hits `dev-{token}` → development database, Redis, storage, variables - request hits `{token}` → production database, Redis, storage, variables Your code asks for `ctx.env.STRIPE_SECRET_KEY` and gets whichever value belongs to the environment handling the request. ## Secret names are shared, values are not This is the part that saves the most pain. You add a variable once: ``` STRIPE_SECRET_KEY dev: sk_test_51H... prod: sk_live_51H... PUBLIC_BASE_URL dev: http://localhost prod: https://app.example.com ``` The name is the contract. Because both environments hold the same names, code that works in development cannot fail in production for a missing variable — the name is there, only the value differs. The reverse is the trap: a variable you add **only** in development is missing in production, and the first request that needs it fails. Add it in both. ## The private dev key A development URL is not public. It answers only when the caller proves they are allowed to use it: - a valid `X-Fenaura-Dev-Key` header, or - an active development session ```bash curl https://edge.fenaura.dev/dev-{token}/api/notes \ -H "X-Fenaura-Dev-Key: $FENAURA_DEV_KEY" ``` Without either, the request is refused before your code runs: ``` 401 DEV_ACCESS_DENIED ``` This is why a development URL can sit in a local bundle or a test script safely. Knowing the URL is not enough; the key is what grants access. It is also why the key should not be committed — it is the only thing standing between a stranger and your development project. ## A development URL goes away after deploy Development URLs are provisional, not permanent. They exist to let you try code before it is live, and they are not the address your application should ship. The rule to hold onto: - Your application calls the **production** URL. - Your tests and your own machine call the **development** URL, with the dev key. - Do not hard-code any URL into a client you distribute; read it from configuration. If a request arrives at a development URL without the key, it is refused. So a misconfigured release does not quietly write test data into someone's project — it fails, visibly, with `DEV_ACCESS_DENIED`. ## What a deploy does A deploy promotes code, not data: 1. The platform compiles the Fleet. A compile error stops the deploy. 2. The new version becomes active for that environment. 3. The previous version is kept in the version timeline. Nothing in the database moves. Deploying production code does not touch development data, and deploying to development does not touch production. ## Next - [The context object](/docs/fleets/context) — reading `ctx.env` and knowing the environment. - [The IDE](/docs/fleets/ide) — deploying from the editor. - [Rate limits](/docs/edge/rate-limits) — preview and production budgets. --- # The database URL: https://docs.fenaura.dev/fleets/database Section: Fleets # The database Every project has a Postgres database. It exists before you write any code, it is already connected, and it is already scoped. ## Nothing to connect There is no connection string, no pool to create, no credential to rotate. Inside a Fleet: ```ts const rows = await ctx.db.query.notes.findMany(); const raw = await ctx.sql`select count(*) from notes`; ``` Both work on the first request. The runtime owns the connection and reuses it across invocations, so a cold start does not open a new one for every call. ## Two ways to query **`ctx.db` — Drizzle.** Typed, composable, safe by default: ```ts const notes = await ctx.db.query.notes.findMany({ where: (t, { eq, and }) => and(eq(t.owner, ctx.user!.id), eq(t.status, 'open')), orderBy: (t, { desc }) => desc(t.created_at), limit: 20, }); ``` **`ctx.sql` — raw.** For aggregates, CTEs and window functions: ```ts const stats = await ctx.sql` select owner, count(*) as total from notes group by owner order by total desc `; ``` Values bind, they do not concatenate: ```ts await ctx.sql`select * from notes where title = ${term}` // correct await ctx.sql`select * from notes where title = '${term}'` // injection risk ``` The same data, the same connection. Use whichever reads better for the query in front of you. ## Development and production are separate databases A development Fleet writes to a development database. A production Fleet writes to the production one. This is physical separation, not a filter: - a row inserted in development does not appear in production - a query run in production cannot see development rows - a migration applied in one does not silently apply in the other A test that clears a table therefore clears the test table. That single fact removes most of the fear from writing a script that deletes things. ## Transactions are not available The database client is a proxy driver and does not support `ctx.db.transaction()`. It throws `Transactions are not supported by the Postgres Proxy driver`. This is worth knowing before you design around it. The patterns that work: **One statement for one atomic write.** A multi-row insert is atomic on its own: ```ts await ctx.sql` insert into ledger (account, amount) values (${from}, -100), (${to}, 100) `; ``` **A unique key for idempotency.** A retried request cannot charge twice: ```ts await ctx.db.insert(payments) .values({ idempotency_key: key, amount }) .onConflictDoNothing(); ``` **Intent first, then effect.** Write the intent row, do the work, mark it done. A crash leaves a row you can finish later — which an in-memory transaction cannot protect you from anyway. Never send email or call a third party inside a transaction you do have; an external side effect cannot be rolled back. ## Schema Tables are defined once and migrated forward. The migration surface is covered in [Database → Migrations](/docs/database/migrations), and the Drizzle language itself at [orm.drizzle.team](https://orm.drizzle.team). Two things worth knowing early: **Add, do not rename.** Adding a nullable column is safe and instant. Renaming one breaks every deploy still running the old code. Add the new column, write to both, then drop the old one in a later deploy. **Give every table an owner.** A `user_id` (or `owner`) column makes row-level security possible and makes "who can see this?" answerable. Adding it later means backfilling rows you can no longer attribute. ## Row-level security Policies let the database enforce ownership, so a bug in one handler cannot leak another user's rows. See [Security and RLS](/docs/database/security) for the model and how to write a policy. The short version: the session sets the current user, and a policy filters rows to that user. Your query then needs no `where` clause to be safe — and if it forgets one, the database still refuses. ## Redis next to it For anything short-lived — a counter, a cache, a rate limit of your own — there is `ctx.redis`, already connected and namespaced per project. Details in [the context object](/docs/fleets/context#ctx-redis). ## Next - [The IDE](/docs/fleets/ide) — deploying from the editor. - [Storage](/docs/storage) — files, next to the rows. - [Veilo](/docs/veilo) — the agent that writes this with you. --- # The IDE URL: https://docs.fenaura.dev/fleets/ide Section: Fleets # The IDE Fleets are edited in a browser IDE, not a text box. Everything below is in the same place, and each part exists because writing server code needs it. ## Files and folders The file tree shows real directories. Create folders, move files, and import between them: ```ts import { listNotes } from './controllers/notes'; import { validate } from '../lib/validate'; ``` Imports resolve the way they do in a bundler, and a compile check runs before a deploy. A file that cannot resolve is an error you see, not a deploy that fails in production. ## The terminal The bottom panel has a terminal that runs **IDE commands**, not a shell. Type `help` for the list: | Command | What it does | |---|---| | `help` | list every command | | `build` | compile the Fleet and report errors | | `deploy` | ship the current code | | `save` | save the current file | | `fmt` | format the code | | `open ` | open a file by name | | `files` | list the project files | | `ctx` | show what the runtime provides | | `env ` | switch the environment view | | `versions` | list past deploys | | `clear` | clear the terminal | It is a command interpreter, and that is the point: the things you do most often while writing code take one word and no mouse. ## Deploying `deploy` from the terminal, or the deploy button. Before it ships: 1. The code is bundled — an error here stops the deploy and points at the line. 2. The new version becomes active for the chosen environment. 3. The previous version stays in the version timeline. Deploying to `test` does not touch production and vice versa. Same code, chosen environment, separate data. ## Versions Every deploy is kept. The timeline lets you see what changed and when, and go back if a change was wrong. This is the safety net that makes deploying without fear reasonable — you are not overwriting the only copy. ## Source control Stage, commit, branch, diff and resolve conflicts. If you have used Git, the terms are what you expect. If you have not, the useful habit is: commit when something works, so you can return to it. ## The problems panel and trash The problems panel lists compile and type errors and jumps to the line. Deleted files go to a trash page, so a delete is recoverable rather than final. ## Where to start - Edit a file in the tree. - `build` to check it compiles. - `deploy` to the test environment. - Call the development URL with your dev key to see it work. Nothing here needs a local toolchain. There is no `npm install`, no Node version to match, and no build machine to keep working — the IDE is the environment. ## Next - [Environments](/docs/fleets/environments) — dev and prod in detail. - [Veilo](/docs/veilo) — the agent that edits alongside you. - [Testing](/docs/fleets/testing) — calling your Fleets safely. --- # Testing URL: https://docs.fenaura.dev/fleets/testing Section: Fleets # Testing You can call a Fleet directly from your machine while building it. No tunnel, no local server, no mock. ## The private dev key The development URL answers only when the caller proves they belong to the project. Two ways, and the first is the usual one: ```bash curl https://edge.fenaura.dev/dev-{token}/api/notes \ -H "X-Fenaura-Dev-Key: $FENAURA_DEV_KEY" ``` Or, in the dashboard, an active development session counts without the header. A request with neither is refused before your code runs: ``` 401 DEV_ACCESS_DENIED ``` The key is what makes it safe to keep a development URL in a `.env` file or a test script. Knowing the URL is not enough. ## From your own code ```ts const res = await fetch( `https://edge.fenaura.dev/dev-${token}/api/notes`, { headers: { 'X-Fenaura-Dev-Key': process.env.FENAURA_DEV_KEY! } }, ); const { notes } = await res.json(); ``` For anything past a smoke test, get a real **end-user session** and send it as a bearer token, so your request is authenticated the way a browser's would be. That is what exercises your route as a user will experience it. ## No separate tool to reach for Most stacks need a testing tool bolted on: Postman for requests, a script for seeding, a dashboard you keep in another tab. The complexity is not the request — it is keeping five things in sync. Fenaura puts the pieces in the same place: **Kora** is the request workspace. It has your routes, an environment per any number of targets, the auth token, and a response viewer — and it saves and replays requests. It also imports **cURL** and **Postman v2** collections, so work you have already done comes with you rather than being retyped. And it syncs to a cloud workspace, so a request you built on one machine is there on another. **Veilo** is the agent. It can do the testing work directly: run a request, read the response, and tell you what failed. Because it can read your database, it can also check the other half of the story — that the row the request should have created actually exists. The practical upshot: the complexity of "set up a testing tool, then keep it current with the API" mostly does not arise. The requests live where the code does, and the agent can inspect both. ## Asking Veilo to check your work Things you can ask, in plain language: - "Call `/api/notes` and show me what it returns." - "Show me the last five rows in `notes`, and who created them." - "Join `orders` with `users` and show me the ten most recent with the user's email." - "Send a test email to me and confirm it landed in the dev mailbox." - "Get the related records for this user — their orders and their notes." That last one is worth noticing. Because Veilo reads the schema, it can follow relations without you writing the join. It is the same work you would do to answer "is this behaving correctly?", asked in one sentence. Anything that would touch **production** or delete data needs your approval first — see [Approvals](/docs/veilo/approvals). ## Email in development An email sent from a development Fleet is **trapped**, not delivered. It lands in the dev mailbox, where you can read it, and no real inbox receives it. This is automatic — it is what the development environment is for. So you can test a signup flow end to end, including the verification email, without a single real address and without a chance of mailing a customer. ## A testing habit that holds Three checks catch most problems: 1. **Call it as a signed-out user.** A private route should return `401`, not data. 2. **Call it as the wrong user.** They should not see another user's rows. 3. **Call it twice.** The second call should not create a duplicate. Each of these is one request in Kora, and Veilo can run them for you. ## Next - [Environments](/docs/fleets/environments) — what changes in production. - [Rate limits](/docs/edge/rate-limits) — the budgets your tests consume. - [Kora](/docs/kora) — the request workspace in detail. --- # Authentication overview URL: https://docs.fenaura.dev/auth Section: Authentication # Authentication overview End users are yours. They are rows in your project's database, not accounts on Fenaura. You can list them, revoke a session, block one, and delete one. ## The three parts **The SDK** runs in the browser and talks to the auth endpoints: signing up, signing in, resending a code, resetting a password. **The proxy** is a small set of files the installer writes into your frontend. They forward auth requests to Fenaura so your secret key stays on your side of the network and never appears in a bundle. **The platform** stores the session and checks it on every edge request. An authentication check you could forget to write does not exist, because you do not write it. ## Install the proxy ```bash npx @fenaura/auth-init ``` The installer asks for your API URL and key, then writes three files into your frontend root. It also adds the routes that serve them. | File | What it does | |---|---| | `fenaura/auth.ts` | Forwards auth requests to Fenaura | | `fenaura/client.ts` | Creates the SDK client against the proxy | | `fenaura/config.json` | The URL and key the other two read | Point your imports at `fenaura/client.ts` rather than calling `createClient` yourself, so the proxy is always in the path. ## Sign up and sign in ```ts import { fenaura } from './fenaura/client'; const { data, error } = await fenaura.auth.signUp({ email: 'ada@example.com', password: 'a-long-enough-password', }); ``` Passwords must be at least eight characters. Every auth method returns `{ data, error }` and never throws. ```ts const { data, error } = await fenaura.auth.signIn({ email: 'ada@example.com', password: 'a-long-enough-password', }); ``` On success, `data.user` is the end user and `data.session` is the session. ## Check who is signed in ```ts const { data } = await fenaura.auth.getUser(); ``` `getUser()` asks the server, so it is the safe check. `getSession()` reads the local session and is faster but can be stale; use it for rendering, and `getUser()` for deciding whether to allow something. ## Sign out ```ts await fenaura.auth.signOut(); ``` This clears the session on the client and revokes it on the server. A revoked session stops working on the next edge request. ## Next - [Methods](/docs/auth/methods) — every call, every option. - [OAuth providers](/docs/auth/oauth) — the twelve, and callback setup. - [Sessions and cookies](/docs/auth/sessions) — what is set and how it is sent. --- # Methods URL: https://docs.fenaura.dev/auth/methods Section: Authentication # Methods Every auth method returns `{ data, error }`. Nothing throws, so check `error` rather than wrapping calls in `try`. ## Password ### `signUp` ```ts const { data, error } = await fenaura.auth.signUp({ email: 'ada@example.com', password: 'a-long-enough-password', data: { display_name: 'Ada' }, // optional extra fields }); ``` | Option | Type | Notes | |---|---|---| | `email` | string | Required | | `password` | string | Required, minimum 8 characters | | `data` | object | Optional fields stored on the end user | If email verification is required for the project, `data.user` comes back with an unverified state and the user must confirm a code before signing in. ### `signIn` ```ts const { data, error } = await fenaura.auth.signIn({ email: 'ada@example.com', password: 'a-long-enough-password', }); ``` `data.user` and `data.session` are both populated on success. ### `signOut` ```ts await fenaura.auth.signOut(); ``` Revokes the session on the server and clears it locally. ## Verification codes ### `sendCode` ```ts await fenaura.auth.sendCode({ email: 'ada@example.com', type: 'verification' }); ``` | `type` | Purpose | |---|---| | `verification` | Confirm an email after sign-up | | `recovery` | Start a password reset | | `email` | Sign in with a one-time code | ### `verifyCode` ```ts const { data, error } = await fenaura.auth.verifyCode({ email: 'ada@example.com', code: '123456', type: 'verification', }); ``` Codes are single-use and expire. A wrong code returns an error without consuming the attempt allowance for the correct one. ## Password reset Two steps, in this order: ```ts // 1. Request the code await fenaura.auth.sendCode({ email: 'ada@example.com', type: 'recovery' }); // 2. Submit the code with the new password const { data, error } = await fenaura.auth.resetPassword({ email: 'ada@example.com', code: '123456', password: 'a-new-long-password', }); ``` You cannot set a new password without the code. That is deliberate: a stolen session must not be enough to take over an account. ## Magic link ```ts await fenaura.auth.signInWithMagicLink({ email: 'ada@example.com' }); ``` The user receives a link. Opening it signs them in and lands them back in your app. Configure the redirect target in the project's auth settings. ## OAuth ```ts await fenaura.auth.signInWithOAuth({ provider: 'github' }); ``` This navigates the browser to the provider. When it returns, the platform exchanges the one-time code for a session. See [OAuth providers](/docs/auth/oauth) for setup and the full list. ## Reading state | Call | Hits the server | Use for | |---|---|---| | `getSession()` | No | Rendering, showing a cached name | | `getUser()` | Yes | Deciding whether to allow an action | ### `onAuthStateChange` ```ts const unsubscribe = fenaura.auth.onAuthStateChange((event, session) => { // event: 'SIGNED_IN' | 'SIGNED_OUT' | 'USER_UPDATED' console.log(event, session?.user?.email ?? 'signed out'); }); // later unsubscribe(); ``` Use this instead of polling. It fires when the session changes in another tab too. ## Next - [OAuth providers](/docs/auth/oauth) — provider list and callbacks. - [Sessions and cookies](/docs/auth/sessions) — what the platform sets. - [End-user management](/docs/auth/end-users) — revoke, block, delete. --- # OAuth providers URL: https://docs.fenaura.dev/auth/oauth Section: Authentication # OAuth providers Fenaura supports twelve providers. All of them use the same call, so switching provider is one string. | Provider | Identifier | |---|---| | Apple | `apple` | | Discord | `discord` | | Facebook | `facebook` | | GitHub | `github` | | GitLab | `gitlab` | | Google | `google` | | LinkedIn | `linkedin` | | Microsoft | `microsoft` | | Slack | `slack` | | Spotify | `spotify` | | Twitch | `twitch` | | Twitter | `twitter` | ## Starting the flow ```ts await fenaura.auth.signInWithOAuth({ provider: 'github', redirectTo: `${window.location.origin}/auth/callback`, }); ``` This sends the browser to the provider. The user approves, the provider sends them back to Fenaura, and Fenaura sends them to your `redirectTo` with a one-time code in the URL. ## Finishing the flow On your callback page, exchange the code for a session: ```ts const params = new URLSearchParams(window.location.search); const code = params.get('code'); if (code) { const { data, error } = await fenaura.auth.exchangeOAuthCode({ code }); if (error) { // show a retry, do not sign the user in } } ``` Codes are single-use and expire after sixty seconds. A code that arrives twice, or arrives late, fails — that is why the exchange must happen on the callback page and not somewhere further into the app. ## Setting up a provider Each provider needs a callback URL registered on its side. The value is the same shape for all of them: ``` https://api.fenaura.dev/dp/api/v1/oauth/{provider}/callback ``` Replace `{provider}` with the identifier from the table. Both the development and production environments need their own registration if you use OAuth in both. You supply the client id and client secret in the project's auth settings. For Google, enable the "People" and "email" scopes; for Apple, generate the client secret from a service key. ## What you get back The end user is created on first sign-in if they do not exist. `data.user` contains the provider's id, the email if the provider released it, and the display name where the provider sent one. Some providers do not release an email address. Do not assume `user.email` is set for OAuth users; key your own records on `user.id`. ## Errors worth handling | Symptom | Cause | |---|---| | `provider_not_configured` | No client id/secret in the project's auth settings | | A redirect back with no `code` | The user cancelled, or the callback URL is not registered | | Exchange fails immediately | The code is older than sixty seconds or was already used | | Works in development, fails in production | The provider has a callback registered for only one environment | ## Next - [Methods](/docs/auth/methods) — the rest of the auth surface. - [Sessions and cookies](/docs/auth/sessions) — what happens after the exchange. --- # Sessions and cookies URL: https://docs.fenaura.dev/auth/sessions Section: Authentication # Sessions and cookies A session is what makes a request "signed in". The platform issues it, the browser or app carries it, and the edge runtime checks it before your function runs. ## The three ways to carry a session In priority order: 1. `Authorization: Bearer ` 2. `X-Fenaura-EUSID: ` 3. A session cookie The header wins over the cookie. That matters for two real cases: a native app or a command-line script has no cookie jar, so it holds the token itself; and a browser extension can send the header without touching the page's cookies. ```bash curl https://edge.fenaura.dev/{token}/api/notes \ -H "Authorization: Bearer " ``` ## The cookie In production the cookie is named `fenaura_eusid`. In development it is `fenaura_eusid_dev`, so both environments can be open in one browser without fighting. It is set with: | Attribute | Value | Why | |---|---|---| | `HttpOnly` | yes | JavaScript cannot read it, so cross-site scripting cannot steal it | | `Secure` | on HTTPS | Never sent over a plain connection | | `SameSite` | `Lax` | Sent on top-level navigation, withheld from cross-site form posts | | `Path` | `/` | Available across the app | Because it is `HttpOnly`, the browser manages it. You never read the token from a cookie in your own code; you call `getSession()` and let the SDK do it. ## Session lifetime Sessions last seven days and extend while they are used. An active user stays signed in; an abandoned session expires on its own. Signing out, or revoking a session from the dashboard, makes it stop working on the next request — not on next refresh. ## When cookies conflict If a request arrives with two session cookies that disagree, the platform refuses it: ``` 401 SESSION_CONFLICT Clear cookies for this site and sign in again. ``` This is deliberate. Accepting one of them silently is how you get a request that runs as the wrong end user, which is far worse than an error. It usually appears after deploying to a new domain, or after switching between a development and a production session in the same browser. ## Reading the session inside an edge function You do not parse anything. The platform has already resolved it: ```ts export default async (req: Request, ctx: FenauraContext) => { if (!ctx.user) { return Response.json({ error: 'Sign in first' }, { status: 401 }); } return Response.json({ id: ctx.user.id, email: ctx.user.email, role: ctx.user.role, }); }; ``` `ctx.user` is `null` only on a route you have made public. On a private route, an unauthenticated request never reaches your handler. ## Next - [End-user management](/docs/auth/end-users) — revoke, block, delete. - [The context object](/docs/edge/context) — every `ctx` field. - [Troubleshooting](/docs/auth/troubleshooting) — what each auth error means. --- # End-user management URL: https://docs.fenaura.dev/auth/end-users Section: Authentication # End-user management End users belong to your project. The dashboard shows them, and lets you take action on one without writing code. ## What you can see For each end user: their id, email, whether the email is verified, when they signed up, when they last signed in, whether they are blocked, and the sessions they currently hold. ## Actions | Action | Effect | Reversible | |---|---|---| | Revoke session | That session stops working on the next request | They sign in again | | Revoke all sessions | Every session for the user stops working | They sign in again | | Block | New sign-ins are refused | Yes, unblock | | Restore | Undoes a soft delete | Yes | | Hard delete | Removes the row | **No** | Revoking is the right move when a device is lost or a token leaks: it costs the user a sign-in and nothing else. Blocking is for stopping a person, not a device. Hard delete removes the row and cannot be undone. Take a backup first. ## Finding one user Search by email. The list is paginated, so on a project with many users search rather than scrolling. ## What a blocked user sees A blocked end user fails sign-in with an error, and any session they already hold is refused on the next edge request. They are not told why — telling an attacker they were blocked and telling a legitimate user the same thing are different problems, so the platform keeps it neutral and you decide what your frontend says. ## Doing it from the API The management API covers the same ground for automation: ``` GET /api/v1/management/projects/:projectId/end-users GET /api/v1/management/projects/:projectId/end-users/:userId GET /api/v1/management/projects/:projectId/end-users/:userId/sessions DELETE /api/v1/management/projects/:projectId/end-users/:userId/sessions POST /api/v1/management/projects/:projectId/end-users/:userId/block POST /api/v1/management/projects/:projectId/end-users/:userId/restore DELETE /api/v1/management/projects/:projectId/end-users/:userId ``` All of them take your management credentials, not an end-user session. ## Next - [Sessions and cookies](/docs/auth/sessions) — what revoking actually stops. - [Quotas](/docs/platform/quotas) — limits that apply to end users. --- # Troubleshooting authentication URL: https://docs.fenaura.dev/auth/troubleshooting Section: Authentication # Troubleshooting authentication Find the symptom, then the cause. Each entry names the thing that is actually wrong. ## `401 SESSION_CONFLICT` The request carried two session cookies that disagreed. **Fix:** clear cookies for the site and sign in again. This appears after deploying to a new domain, or after holding a development and a production session in the same browser. ## `401 UNAUTHORIZED` on a route you are sure is signed in Three candidates, in order of likelihood: 1. **The request has no session at all.** Check the browser's request headers for the cookie, or that your `curl` sends `Authorization`. 2. **The session was revoked.** Signing out elsewhere, or a revoke from the dashboard, kills it on the next request. 3. **The cookie is on the wrong domain.** A cookie set on `app.example.com` is not sent to `api.example.com`. ## Sign-in succeeds but `ctx.user` is null `ctx.user` is only `null` on a **public** route. If it is null on a route you believe is private, the route is declared public somewhere in the function config. Check the hoisted route list. ## `403 EMAIL_NOT_VERIFIED` The project requires verified emails. Send and confirm a code before signing in: ```ts await fenaura.auth.sendCode({ email, type: 'verification' }); await fenaura.auth.verifyCode({ email, code, type: 'verification' }); ``` Or turn off the requirement in auth settings while you develop. ## OAuth returns with no `code` The provider sends `code` only on success. No `code` means the user cancelled, or the callback URL is not registered on the provider's side for the environment you are using. ## `provider_not_configured` No client id and secret are stored for that provider. Add them in the project's auth settings. A provider configured in development is not configured in production — they are separate. ## The proxy files were written but calls fail You are probably calling `createClient` directly instead of importing the client the installer generated. The proxy only works if requests go through it. Import from `fenaura/client.ts`. ## Password is rejected as too short The minimum is eight characters. There is no other rule — no required symbols, no required digits. ## A reset link does not work Reset is two steps and the second needs the code from the first. Opening the email's link signs nobody in; it takes you to a page where you submit the code with the new password. ## Still stuck Ask the agent inside the dashboard. It has the same knowledge as these docs plus the ability to read your project's configuration, which is usually what the problem is about. ## Next - [Sessions and cookies](/docs/auth/sessions) — the credential model. - [Methods](/docs/auth/methods) — the correct call shapes. --- # Database overview URL: https://docs.fenaura.dev/database Section: Database # Database overview Every project gets its **own Postgres database** — a whole database, created when the project is, with its own owner role. Not a schema inside a database shared with other projects, and not a table prefix you have to remember to filter on. That distinction matters more than it sounds: - your tables cannot collide with another project's table names - you can create schemas, extensions and roles that are yours alone - a migration you run touches your database and nothing else - `\dt` lists your tables, not everyone's You reach it two ways, both already connected. **From the browser**, through the SDK query builder: ```ts const { data } = await fenaura.from('notes').select('*').limit(20).execute(); ``` **From an edge function**, either with Drizzle over `ctx.db`: ```ts const notes = await ctx.db.query.notes.findMany({ limit: 20 }); ``` or with raw SQL through `ctx.sql`: ```ts const notes = await ctx.sql`select * from notes limit 20`; ``` Use the builder for simple reads and writes, and raw SQL when a query is easier to express than to assemble. ## Tables Create and change tables in the dashboard's database page, or have the agent write a migration for you. Columns, indexes, defaults and constraints are all editable there. Sensitive columns can be marked — email addresses, phone numbers, tokens — and the platform masks them in logs, in tool output, and in the agent's view unless you explicitly ask for the value. ## Access control Two layers: 1. **Automatic.** Every edge route is session-gated, so an unauthenticated request never reaches your code and never touches the database. 2. **Row-level security.** Policies decide which rows a given end user may read or write. This is where "user A cannot see user B's notes" lives. See [Security and RLS](/docs/database/security). ## Limits worth knowing | Limit | Value | |---|---| | Rows per query response | 1000 | | Items in an `in()` list | 100 | | Concurrent writes | Guarded by Postgres; use `upsert` for idempotence | A query asking for more than 1000 rows is capped at 1000. Page with `range` or `limit`/`offset` rather than expecting one huge response. ## Next - [Querying](/docs/database/querying) — the builder. - [Filtering](/docs/database/filtering) — every operator. - [Security and RLS](/docs/database/security) — policies and sensitive columns. --- # Querying URL: https://docs.fenaura.dev/database/querying Section: Database # Querying The builder chains calls and returns `{ data, error }`. Learn one rule about when a chain runs and the rest follows. ## When a chain runs | Call | Runs | |---|---| | `insert` | Immediately | | `upsert` | Immediately | | `.single()` / `.maybeSingle()` | Immediately | | `update` | Only with `.execute()` or a terminal | | `delete` | Only with `.execute()` or a terminal | | `select` | Only with `.execute()` or a terminal | An `update` chain that is awaited but never terminated does nothing, silently. That is the single most common mistake with this builder. These docs always show `.execute()` so the rule cannot bite you. ```ts // Nothing happens await fenaura.from('notes').update({ title: 'New' }).eq('id', id); // Correct await fenaura.from('notes').update({ title: 'New' }).eq('id', id).execute(); ``` ## Select ```ts const { data, error } = await fenaura .from('notes') .select('id, title, created_at') .execute(); ``` `'*'` selects every column. Name columns explicitly for anything that will run often — it is smaller over the wire and it does not break when you add a column of sensitive data. ```ts // Nested relationships const { data } = await fenaura .from('notes') .select('id, title, author:users(id, display_name)') .execute(); ``` ## Insert ```ts const { data } = await fenaura .from('notes') .insert({ title: 'First note', body: 'Hello' }) .execute(); ``` One object inserts one row; an array inserts many. Upsert refuses arrays — send one row per call when you need upsert semantics. ## Update ```ts await fenaura.from('notes').update({ title: 'Renamed' }).eq('id', id).execute(); ``` An update with no filter is refused rather than applied to every row. Add `.eq()`, `.in()` or another filter. ## Delete ```ts await fenaura.from('notes').delete().eq('id', id).execute(); ``` Same rule: no filter, no delete. ## Upsert ```ts await fenaura .from('notes') .upsert({ id, title: 'Draft' }, { onConflict: 'id' }) .execute(); ``` Idempotent by design: run it twice and the second run updates instead of failing. Use it for anything a retry might replay. ## Single rows ```ts const { data, error } = await fenaura.from('notes').select('*').eq('id', id).single(); ``` `.single()` errors when there is no row or more than one. `.maybeSingle()` returns `data: null` when there is no row and errors only on more than one. Prefer `.maybeSingle()` when "not found" is a normal outcome. ## Counting ```ts const { count } = await fenaura .from('notes') .select('*', { count: 'exact', head: true }) .execute(); ``` `head: true` returns the count without the rows, which is much cheaper than fetching and measuring. ## Pagination ```ts const page = 2; const size = 20; const { data } = await fenaura .from('notes') .select('*') .order('created_at', { ascending: false }) .range((page - 1) * size, page * size - 1) .execute(); ``` `range` is inclusive on both ends. For a feed that grows while the user scrolls, page by a stable key (a timestamp plus id) rather than by offset, or rows will repeat and skip. ## Ordering ```ts .order('created_at', { ascending: false }) .order('title', { ascending: true, nullsFirst: false }) ``` ## Next - [Filtering](/docs/database/filtering) — every operator. - [Drizzle in the edge](/docs/database/drizzle) — the server-side style. --- # Filtering URL: https://docs.fenaura.dev/database/filtering Section: Database # Filtering Filters are methods on the chain. Each one takes a column and a value. ## Comparison ```ts .eq('status', 'published') // status = 'published' .neq('status', 'draft') // status <> 'draft' .gt('views', 100) // views > 100 .gte('views', 100) // views >= 100 .lt('views', 100) // views < 100 .lte('views', 100) // views <= 100 ``` ## Null and boolean ```ts .is('deleted_at', null) // deleted_at is null .is('published', true) // published = true ``` `is` is for `null` and booleans only. For everything else use `eq`. ## Sets ```ts .in('status', ['draft', 'published']) // status in (...) .not('status', 'in', ['archived']) // status not in (...) ``` An `in` list is capped at 100 items. For a longer list, use a join or a temporary table instead of a bigger list. ## Text ```ts .like('title', 'Hello%') // case-sensitive, % and _ are wildcards .ilike('title', 'hello%') // case-insensitive ``` ```ts .textSearch('body', 'postgres') // full-text search ``` `ilike` is the usual choice for a search box. `textSearch` is for real full-text search with stemming and ranking; use it when the column has a text index. ## JSON and arrays ```ts .contains('tags', ['featured']) // array or JSON contains .containedBy('tags', ['a', 'b', 'c']) .overlaps('tags', ['urgent', 'later']) ``` `contains` asks "does it have all of these", `overlaps` asks "does it have any of these", and `containedBy` asks "is it entirely inside this set". ## Combining Filters on one chain are combined with AND: ```ts const { data } = await fenaura .from('notes') .select('*') .eq('owner', userId) .eq('status', 'published') .gte('views', 10) .execute(); ``` That is `owner = ? and status = ? and views >= ?`. ## OR ```ts const { data } = await fenaura .from('notes') .select('*') .or('status.eq.draft,status.eq.published') .execute(); ``` The string form is `column.operator.value`, comma-separated. ## Negating ```ts .not('status', 'eq', 'archived') .not('title', 'ilike', '%test%') ``` `not` wraps any operator. ## Wildcards that bite Inside `like` and `ilike`, `%` matches any run of characters and `_` matches one. A user-typed search containing `%` will match far more than they meant. Escape it before building the pattern: ```ts const safe = term.replace(/[%_\\]/g, '\\$&'); .ilike('title', `%${safe}%`) ``` ## Performance A filter on an indexed column is fast. A filter with `ilike '%term%'` cannot use a normal index, because the wildcard is at the front. For a large table, add a trigram index or switch to `textSearch`. ## Next - [Querying](/docs/database/querying) — ordering and pagination. - [Security and RLS](/docs/database/security) — why filters are not your only defence. --- # Drizzle in the edge URL: https://docs.fenaura.dev/database/drizzle Section: Database # Drizzle in the edge Inside an edge function the database is ready. There is no connection string to load, no pool to create, and no teardown — the runtime owns the connection. ```ts export default async (req: Request, ctx: FenauraContext) => { const notes = await ctx.db.query.notes.findMany({ where: (t, { eq }) => eq(t.owner, ctx.user!.id), limit: 20, }); return Response.json({ notes }); }; ``` ## ctx.db `ctx.db` is a Drizzle client with your project's tables in scope as `ctx.db.query.`. ### findMany ```ts const rows = await ctx.db.query.notes.findMany({ where: (t, { eq, and, gte }) => and(eq(t.owner, userId), gte(t.views, 10)), orderBy: (t, { desc }) => desc(t.created_at), limit: 20, offset: 0, with: { author: true }, // relations, if defined }); ``` ### findFirst ```ts const note = await ctx.db.query.notes.findFirst({ where: (t, { eq }) => eq(t.id, id), }); ``` `findFirst` returns the row or `undefined`, which reads more naturally than destructuring an array of one. ### insert, update, delete ```ts await ctx.db.insert(notes).values({ title: 'New note', owner: userId }); await ctx.db.update(notes).set({ title: 'Renamed' }).where(eq(notes.id, id)); await ctx.db.delete(notes).where(eq(notes.id, id)); ``` The Drizzle forms here do run when awaited; the "needs a terminal" rule belongs to the browser builder, not to `ctx.db`. ## ctx.sql For anything easier to write as SQL: ```ts const rows = await ctx.sql` select id, title from notes where owner = ${userId} order by created_at desc limit 20 `; ``` Tagged-template values are parameterised, not interpolated. Never build SQL by concatenating a value into the string — that is how injection gets in. ```ts // Never do this await ctx.sql`select * from notes where title = '${term}'`; // Do this await ctx.sql`select * from notes where title = ${term}`; ``` ## Transactions are not available The edge database client is a proxy driver, and it does not support `ctx.db.transaction()`. Calling it throws: ``` Transactions are not supported by the Postgres Proxy driver ``` This is a real limitation, not a style preference. Write around it rather than assuming a rollback you will not get. **For a multi-row write that must be atomic,** send it as one SQL statement. A single `insert ... values (...), (...)` or an `update` with a `case` is atomic on its own: ```ts await ctx.sql` insert into ledger (account, amount) values (${from}, -100), (${to}, 100) `; ``` **For work that must be idempotent,** give it a key and let a unique constraint do the guarding: ```ts await ctx.db.insert(payments) .values({ idempotency_key: key, amount }) .onConflictDoNothing(); ``` A second attempt with the same key inserts nothing, so a retried request cannot charge twice. **For multi-step logic,** order the writes so a failure is recoverable — write the intent row first, then the effect, and let a later request finish anything left half-done. This is the ordinary outbox pattern, and it survives a crash in a way an in-memory transaction never could. Do not build on `ctx.db.transaction`. It will not run. ## Which to use | Reach for | When | |---|---| | `ctx.db` | You want types, and the query is a normal read or write | | `ctx.sql` | Aggregates, window functions, CTEs, or a query clearer as SQL | Both run on the same connection and see the same data. Mixing them in one function is fine. ## The full Drizzle language The pages here cover how the client is handed to you. The query language itself is Drizzle's, so for schemas, relations, `with`, transactions, prepared statements and typed selects, read [orm.drizzle.team](https://orm.drizzle.team). A Fenaura Fleet runs real Drizzle — the documentation there applies as written, because nothing about the API has been changed. ## Next - [Migrations](/docs/database/migrations) — how schema changes ship. - [Security and RLS](/docs/database/security) — policies. --- # Migrations URL: https://docs.fenaura.dev/database/migrations Section: Database # Migrations A migration is a schema change that has to happen on a database already holding data. It is the riskiest routine task in a project, so the rules here are about not losing rows. ## Making a change Edit the table in the dashboard's database page, or have the agent write the migration. Either way it is applied as a versioned change, so you can see what ran and when. ## The safe order For every change, in this order: 1. **Add** the new column or table, nullable or with a default. 2. **Write** to both the old and the new place while code rolls out. 3. **Backfill** existing rows. 4. **Switch** reads to the new place. 5. **Remove** the old column or table in a later migration. Doing steps 1 to 5 in one deploy is the thing that causes outages: code still running the old version meets a column that no longer exists. ## Renaming a column There is no safe rename in one step. A rename is an add, a backfill, a switch, then a drop. Anything reading the old name breaks the moment it disappears. ## Adding a NOT NULL column Three steps: ```sql alter table notes add column status text; -- nullable update notes set status = 'draft' where status is null; -- backfill alter table notes alter column status set not null; -- then constrain ``` Adding `not null` to a column with existing nulls fails, and on a large table it locks for longer than you expect. ## Indexes ```sql create index concurrently notes_owner_idx on notes (owner); ``` `concurrently` builds the index without blocking writes. Use it on any table that is taking traffic; a normal `create index` holds a lock for the whole build. ## Development first Apply every migration to development, run your app against it, then promote. A migration that is fine on an empty table can be slow or failing on a table with a million rows, and development is where finding that out is cheap. ## Backups Take a backup before any migration that drops or rewrites a column. Confirm the backup restores into a development environment before you touch production — an untested backup is not a backup. ## Next - [Security and RLS](/docs/database/security) — policy changes need the same care. - [Drizzle in the edge](/docs/database/drizzle) — reading the new shape. --- # Security and RLS URL: https://docs.fenaura.dev/database/security Section: Database # Security and RLS Two independent things protect your data: the platform refuses unauthenticated requests, and row-level security decides which rows a signed-in user may touch. Both have to be right. ## The rule that matters **A filter in client code is a convenience, not a defence.** If your app sends `.eq('owner', userId)` from the browser, that filter decides what the *honest* caller sees. Someone calling the API directly writes their own filters. The database must refuse the row on its own. That refusal is row-level security. ## Turning it on Enable RLS on the table. A table with RLS enabled and no policy grants nothing — the safe default. ```sql alter table notes enable row level security; ``` ## Policies A policy is a rule for a role and an operation. ```sql -- Read your own notes create policy "own notes readable" on notes for select using (owner = auth.uid()); -- Write your own notes create policy "own notes writable" on notes for insert with check (owner = auth.uid()); -- Update and delete your own create policy "own notes updatable" on notes for update using (owner = auth.uid()) with check (owner = auth.uid()); ``` `using` filters which existing rows the operation can see. `with check` validates the row being written. An `update` needs both, or a user can move a row out of their own scope. ## Testing a policy Sign in as user A and try to read user B's row. Not from your app — from a direct call, which is what an attacker does: ```bash curl https://edge.fenaura.dev/{token}/api/notes/ \ -H "Authorization: Bearer " ``` An empty result or a `404` is correct. A row is a policy bug. Then test the write paths: insert a row owned by someone else, update one, delete one. Each should be refused. ## Sensitive columns Mark email addresses, phone numbers, tokens and any personal identifier as sensitive. The platform then: - masks the value in logs - masks it in tool output the agent sees - refuses to return it to the agent unless you explicitly ask for that column This keeps a debugging session from turning into a data leak, and it stops an agent transcript from becoming a copy of your user table. ## Service-side checks Inside an edge function you are behind the trust boundary, so a query can read everything. That means the function must apply its own constraint: ```ts // Correct: scoped to the caller const notes = await ctx.db.query.notes.findMany({ where: (t, { eq }) => eq(t.owner, ctx.user!.id), }); // Wrong: no scope, returns every user's notes const notes = await ctx.db.query.notes.findMany({}); ``` RLS does not save you here, because the function runs with the project's own authority. The scoping is your responsibility. ## A short checklist - [ ] RLS enabled on every table holding user data - [ ] A policy per operation, not one broad policy - [ ] `update` policies have both `using` and `with check` - [ ] Sensitive columns marked - [ ] Every edge query filtered by the caller - [ ] Policies tested by direct API call, not through the UI ## Next - [Migrations](/docs/database/migrations) — shipping a policy change safely. - [Sessions and cookies](/docs/auth/sessions) — who the caller is. --- # Edge functions URL: https://docs.fenaura.dev/edge Section: Edge functions # Edge functions An edge function is your backend. It runs in a V8 isolate — the browser's JavaScript engine without the browser — which starts in milliseconds and does not keep state between requests. One function per environment. A deployment replaces the whole thing, so a route you delete is gone. ## Private by default This is the property that shapes how you write handlers. Every route is checked for a signed-in end user **before your code runs**. ```ts export default async (req: Request, ctx: FenauraContext) => { // Reaching this line means someone signed in. return Response.json({ hello: ctx.user!.email }); }; ``` An unauthenticated request to a private route is rejected upstream. Your handler never sees it. There is no `if (!user) return 401` to forget, and no route left open by omission. To serve anonymous traffic you declare the route public. That is one deliberate act, visible in one place, rather than a scattered convention. See [Public routes](/docs/edge/public-routes) for what that really changes. ## Deploying Write the function in the dashboard's edge editor, or let the agent write it, and deploy. The code is bundled at deploy time, so imports are resolved then — there is no `npm install` at runtime and no `npm:` specifier that resolves later. ## Calling it Production: ``` https://edge.fenaura.dev/{token}/your/path ``` Development: ``` https://edge.fenaura.dev/dev-{token}/your/path ``` `{token}` is the project's edge token, on the project's overview page. Append the route path directly. There is no `/default` segment. A development URL needs your dev key or an active development session — that check happens before your function runs too. ## What an edge function is not - **Not a container.** No shell, no filesystem that survives a request. - **Not long-running.** A request has a time limit; work that takes minutes belongs in a queue elsewhere. - **Not stateful.** Each request may land on a fresh isolate. Use the database or `ctx.redis` for anything that must persist. ## Next - [Your first function](/docs/edge/first-function) — build and deploy one. - [The context object](/docs/edge/context) — every field on `ctx`. - [Rate limits](/docs/edge/rate-limits) — the real numbers. - [Errors](/docs/edge/errors) — the codes and what to do about them. --- # Your first function URL: https://docs.fenaura.dev/edge/first-function Section: Edge functions # Your first function This page builds a function that returns the signed-in user's notes. It exercises the three things you will use most: routing, `ctx.user`, and `ctx.db`. ## 1. Write it In the project's edge editor, create a route: ```ts // GET /api/notes export default async (req: Request, ctx: FenauraContext) => { const notes = await ctx.db.query.notes.findMany({ where: (t, { eq }) => eq(t.owner, ctx.user!.id), orderBy: (t, { desc }) => desc(t.created_at), limit: 20, }); return Response.json({ notes }); }; ``` Three things are already true when this runs: - The caller is signed in, because the route is private by default. - `ctx.user` is the end user object, already parsed. - `ctx.db` is connected to this project's database. ## 2. Deploy Deploy to development first. The deploy replaces the whole function, so anything already in it stays only if it is in the code you deployed. ## 3. Call it Copy the project's edge token from the overview page: ```bash curl https://edge.fenaura.dev/dev-{token}/api/notes \ -H "Authorization: Bearer " \ -H "X-Fenaura-Dev-Key: " ``` Without the session token you get `401` — and that is the point. The function never ran. ## 4. Add a write ```ts // POST /api/notes export default async (req: Request, ctx: FenauraContext) => { const body = await req.json(); if (typeof body?.title !== 'string' || !body.title.trim()) { return Response.json({ error: 'A title is required' }, { status: 400 }); } const [note] = await ctx.db .insert(notes) .values({ title: body.title.trim(), owner: ctx.user!.id }) .returning(); return Response.json({ note }, { status: 201 }); }; ``` Validate the body before using it. `ctx.user` is trustworthy because the platform produced it; `body` is whatever the caller chose to send. ## 5. Promote Deploy the same code to production and call it at `https://edge.fenaura.dev/{token}/api/notes`. The data in production is separate from development, so expect an empty list. ## Mistakes that cost time | Mistake | Symptom | |---|---| | Appending `/default` to the URL | `404` — there is no such segment | | Using a development URL without the dev key | `401` before your function runs | | Forgetting `.where()` on a query | Every user's rows come back | | Trusting `body` without validation | Whatever the caller sent becomes your input | | Not awaiting the insert | Response returns before the row exists | ## Next - [Routing](/docs/edge/routing) — Hono and multiple files. - [The context object](/docs/edge/context) — every field. - [Errors](/docs/edge/errors) — codes you will meet. --- # The context object URL: https://docs.fenaura.dev/edge/context Section: Edge functions # The context object Every handler receives `(req, ctx)`. `req` is a standard web `Request`. `ctx` is everything the platform gives you. ```ts export default async (req: Request, ctx: FenauraContext) => { return Response.json({ env: ctx.environment, requestId: ctx.requestId, user: ctx.user?.email ?? null, }); }; ``` ## Fields ### `ctx.environment` `'test'` or `'production'`. Use it to vary behaviour when you have to, and to refuse to accept a test-only shortcut in production. ```ts if (ctx.environment === 'production' && body.seedDemo) { return Response.json({ error: 'Not allowed in production' }, { status: 400 }); } ``` ### `ctx.requestId` A unique id for this request. Return it in error responses and log it — it is the one value that lets you find the exact request later. ```ts return Response.json({ error: 'failed', requestId: ctx.requestId }, { status: 500 }); ``` ### `ctx.project` ```ts ctx.project.id // string ctx.project.slug // string ctx.project.name // string ``` Rarely needed, since the function only ever runs for one project. Useful in a log line or a webhook payload. ### `ctx.user` The signed-in end user, or `null` on a public route. ```ts ctx.user.id // string ctx.user.email // string ctx.user.role // string ``` `null` only happens on a route you declared public. On a private route, an unauthenticated request never reaches your handler. ### `ctx.env` The environment variables for this environment, as strings. ```ts const stripeKey = ctx.env.STRIPE_SECRET_KEY; ``` Development and production variables are separate. A key set in development is not set in production — check for `undefined` or you will discover the gap in production. ### `ctx.db` A Drizzle client scoped to this project. See [Drizzle in the edge](/docs/database/drizzle). ### `ctx.sql` Raw SQL, parameterised through a tagged template. ```ts const rows = await ctx.sql`select count(*) from notes where owner = ${ctx.user!.id}`; ``` Values are bound, not interpolated. Never build SQL by concatenating. ### `ctx.redis` A key-value store for short-lived shared state — counters, rate limits of your own, caches. ```ts const count = await ctx.redis.incr(`views:${id}`); await ctx.redis.expire(`views:${id}`, 3600); ``` Entries take a TTL and count against the project's Redis quota, so they cannot grow without bound. Development and production have separate namespaces; a key written in one is not visible in the other. ## What is not on ctx There is no filesystem, no `process.env` (use `ctx.env`), and no shell. Relying on `process` at all will fail — an edge function is not Node. ## Next - [Environments](/docs/edge/environments) — test versus production. - [Rate limits](/docs/edge/rate-limits) — how much you can send. - [Errors](/docs/edge/errors) — the codes and the fixes. --- # Routing URL: https://docs.fenaura.dev/edge/routing Section: Edge functions # Routing A handler is a function of `(req, ctx)`. The path decides which handler runs. ## The simple shape The smallest function routes by inspecting the request: ```ts export default async (req: Request, ctx: FenauraContext) => { const url = new URL(req.url); if (url.pathname === '/api/notes' && req.method === 'GET') { return Response.json({ notes: [] }); } return new Response('Not found', { status: 404 }); }; ``` This is fine for one or two routes and becomes unreadable at ten. At that point, structure it. ## Path parameters ```ts const match = url.pathname.match(/^\/api\/notes\/([^/]+)$/); if (match) { const id = match[1]; // ... } ``` Always validate the captured value before using it as a database key. A path parameter is caller-controlled input. ## Hono for larger functions Hono gives you a router with typed parameters and middleware, and it runs fine in a V8 isolate: ```ts import { Hono } from 'hono'; const api = new Hono(); api.use('*', async (c, next) => { const started = Date.now(); await next(); console.log(c.req.method, c.req.path, c.res.status, `${Date.now() - started}ms`); }); api.get('/notes', async (c) => { const ctx = c.get('fenaura') as FenauraContext; const notes = await ctx.db.query.notes.findMany({ limit: 20 }); return c.json({ notes }); }); api.get('/notes/:id', async (c) => { const ctx = c.get('fenaura') as FenauraContext; const note = await ctx.db.query.notes.findFirst({ where: (t, { eq }) => eq(t.id, c.req.param('id')), }); if (!note) return c.json({ error: 'Not found' }, 404); return c.json({ note }); }); export default (req: Request, ctx: FenauraContext) => { return api.fetch(req); }; ``` Passing `ctx` through Hono's context keeps the database and env bindings reachable in every handler. ## Splitting across files For a function with distinct areas, keep one route file per area and one entry that composes them: ```ts // routes/notes.ts import { Hono } from 'hono'; export const notes = new Hono(); notes.get('/', listNotes); notes.post('/', createNote); // routes/users.ts export const users = new Hono(); users.get('/me', whoAmI); // index.ts import { Hono } from 'hono'; import { notes } from './routes/notes'; import { users } from './routes/users'; const app = new Hono(); app.route('/api/notes', notes); app.route('/api/users', users); export default (req: Request, ctx: FenauraContext) => app.fetch(req); ``` Imports are bundled at deploy time, so relative paths work and there is no runtime resolution step. ## Which method is allowed Return `405` when the path exists but the method does not, rather than `404`. It tells a caller they have the right address and the wrong verb: ```ts if (url.pathname === '/api/notes') { if (req.method !== 'GET' && req.method !== 'POST') { return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, POST' } }); } } ``` ## Next - [The context object](/docs/edge/context) — what each handler receives. - [Errors](/docs/edge/errors) — status codes and codes. --- # Environments URL: https://docs.fenaura.dev/edge/environments Section: Edge functions # Environments Every project has two environments running the same kind of code with completely separate state. | | Development | Production | |---|---|---| | Edge URL | `edge.fenaura.dev/dev-{token}` | `edge.fenaura.dev/{token}` | | Access | dev key or active dev session | a signed-in end user | | Data | separate database | real user data | | Env vars | separate | separate | | Auth users | separate | real end users | Nothing moves between them by default. That is the point: trying something in development cannot touch a real user. ## Deploying Deploy to development, check it with real requests, then promote the same code to production. Promotion copies code, not data. ## Environment variables Set each variable in both environments. A key present in development is `undefined` in production, and the failure appears at the first request that needs it: ```ts const secret = ctx.env.STRIPE_SECRET_KEY; if (!secret) { return Response.json( { error: 'STRIPE_SECRET_KEY is not set in this environment', code: 'MISSING_ENV' }, { status: 500 }, ); } ``` Check for the variable and say so in the error. Finding a missing variable from a generic 500 costs twenty minutes; naming it costs one line. ## Calling a development deployment A development URL needs proof that you are allowed to use it: ```bash curl https://edge.fenaura.dev/dev-{token}/api/notes \ -H "X-Fenaura-Dev-Key: " \ -H "Authorization: Bearer " ``` The dev key requirement is why a development URL is safe to have in a frontend bundle: knowing the URL is not enough. ## Common mistakes | Mistake | What happens | |---|---| | Setting a variable in one environment only | `undefined` at runtime in the other | | Testing against production data | Real users see test rows | | Pointing a preview deploy at production | Test traffic counted against real limits | | Assuming promoted code brings its env vars | Variables are per environment, always | ## A safe pattern Keep the source of truth in code, and keep secrets in variables: ```ts // Reads the same in both environments const baseUrl = ctx.env.PUBLIC_BASE_URL; // Differs per environment, as it should const stripeKey = ctx.env.STRIPE_SECRET_KEY; ``` Data that differs between environments belongs in `ctx.env`. Anything else should not differ at all. ## Next - [The context object](/docs/edge/context) — `ctx.environment` and `ctx.env`. - [Rate limits](/docs/edge/rate-limits) — preview runs have their own limit. --- # Public routes URL: https://docs.fenaura.dev/edge/public-routes Section: Edge functions # Public routes Every route is private by default. Making one public is a deliberate act, and it is the only way to serve a request from someone who is not signed in. ## Declaring a route public Add the route to the function's public list. It is matched by method and path: ``` POST /webhooks/stripe GET /api/health ``` Anything listed there skips the session check. Everything else keeps it. ## What changes Three things, and all three matter: **1. `ctx.user` is `null`.** The platform has no session, so there is no user to populate. Your handler must decide what to do — usually validate a signature or an API key instead. **2. The rate limit is per project.** A private route counts per end user, so one heavy client cannot starve others. A public route has no user to count against, so the entire project shares 1000 requests per 10 seconds. A traffic spike on a public route can throttle your authenticated routes too. **3. You are now responsible for identification.** There is nothing upstream proving who called. If the route acts on behalf of someone, verify something: ```ts // A webhook: verify the signature, not the caller's identity const signature = req.headers.get('stripe-signature'); if (!signature || !verifyStripeSignature(await req.text(), signature, ctx.env.STRIPE_WEBHOOK_SECRET)) { return new Response('Invalid signature', { status: 400 }); } ``` Never trust a body that claims an identity. `{ userId: 'abc' }` from an anonymous caller means nothing. ## Good reasons to go public | Reason | Protection you add | |---|---| | Receiving a provider webhook | Signature verification | | A health check for monitoring | Nothing needed; return no data | | A public landing-page counter | Rate limit through `ctx.redis` | | An OAuth callback landing page | Validate the one-time code | ## Bad reasons to go public - "It is easier to test without signing in." Sign a test user in instead. - "The frontend calls it before login." Call it after login. - "The data is not sensitive." Then it does not need to be an endpoint at all. ## Adding your own limit Public routes get a per-project limit only. Add a per-caller limit where you need one: ```ts const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; const key = `rl:pub:${ctx.project.id}:${ip}`; const hits = await ctx.redis.incr(key); if (hits === 1) await ctx.redis.expire(key, 60); if (hits > 30) return new Response('Too many requests', { status: 429 }); ``` ## Reviewing what is public The public list is short and lives in one place — read it before every deploy. A route left public after an experiment is the most common way an app leaks data, and it is invisible in the UI because the endpoint still works. ## Next - [Rate limits](/docs/edge/rate-limits) — the full table. - [Security and RLS](/docs/database/security) — protecting the rows behind a public route. --- # Rate limits URL: https://docs.fenaura.dev/edge/rate-limits Section: Edge functions # Rate limits Limits are applied before your function runs, by the runtime. Crossing one returns `429` with an error code, and your code does not execute. ## The numbers | Scope | Limit | Window | Error code | |---|---|---|---| | Authenticated requests, per project | 1000 | 10 seconds | `PROJECT_THROTTLED` | | Authenticated requests, per end user | 200 | 10 seconds | `USER_THROTTLED` | | Public routes, per project | 1000 | 10 seconds | `PROJECT_THROTTLED` | | Preview runs, per developer | 60 | 60 seconds | `PREVIEW_THROTTLED` | | Preview runs, per IP | 30 | 60 seconds | `PREVIEW_THROTTLED` | Two of these are worth reading twice. **Per end user is 200 in 10 seconds.** That is a fast, chatty client hammering your own endpoint — usually a polling loop with no backoff. It is not a reasonable interactive rate, and a user who hits it has a bug on your side, not theirs. **Public routes are per project, not per visitor.** A public route has no end user to count against, so the whole project shares one budget of 1000 requests per 10 seconds. If you expect anonymous traffic at volume, cache at the edge or move the endpoint behind a session. ## Your Fleet calls have a second, slower budget Edge requests are metered per 10 seconds. The **database and API calls those requests make** are metered on a *different window* — per 60 seconds: | Scope | Limit | Window | Error code | |---|---|---|---| | Data requests, per project | 1000 | 60 seconds | `PROJECT_THROTTLED` | | Data requests, per end user | 200 | 60 seconds | `USER_THROTTLED` | This matters more than it looks. A handler that makes many queries in one request spends the data budget, not the edge budget. Twenty `findMany` calls in a loop are twenty units in a 60-second window, and the limit surfaces as `429 USER_THROTTLED` on a route that looks lightly used from the outside. The fix is the ordinary one: batch the queries, select only the columns you need, and let the database do the joining rather than looping in JavaScript. ## Public routes have a third guard: per-visitor A public route is reached before any session exists, so it is also metered *per visitor* — not only per project. The runtime uses a hardened cookie to recognise a repeat caller and counts them on a short window: | Scope | Limit | Window | Error code | |---|---|---|---| | Anonymous caller on a public route | 60 | 60 seconds | `PUBLIC_ROUTE_RATE_LIMITED` | | The same caller, burst | 15 | 5 seconds | `PUBLIC_ROUTE_RATE_LIMITED` | Both defaults come from the route's own rate-limit configuration, so you can tune them per route. The limits are the defaults. The caller is identified by a signed cookie (`fenaura_anon_rl`) combined with a hash of their IP and user agent. This is why a public route is safe to expose at moderate volume: one visitor cannot spend the whole project budget, and clearing cookies does not reset the IP-and-device part of the check. When it trips, the response is `429` and carries the usual headers so a client can back off: ``` 429 PUBLIC_ROUTE_RATE_LIMITED retry-after: 60 x-ratelimit-limit: 60 x-ratelimit-remaining: 0 ``` ## What is not a limit There is no per-IP anonymous limiter on routes that require a session — a caller without a session is refused outright with `401 SESSION_MISSING` before any counting happens. Anonymous metering exists only on routes you have declared public. nothing else. If you need per-visitor limits on a public route, implement them yourself with `ctx.redis`: ```ts const key = `rl:${ip}:${ctx.project.id}`; const hits = await ctx.redis.incr(key); if (hits === 1) await ctx.redis.expire(key, 60); if (hits > 30) return new Response('Too many requests', { status: 429 }); ``` Read the caller's address from `req.headers.get('x-forwarded-for')`. It arrives from the platform's proxy; treat it as advisory if your route is public and could be reached through another proxy. ## Handling a 429 correctly Respect the retry hint. A fixed one-second retry on a 10-second window makes the problem worse, because every retry counts. ```ts async function callWithRetry(url: string, init: RequestInit, attempt = 0): Promise { const res = await fetch(url, init); if (res.status !== 429 || attempt >= 3) return res; const retryAfter = Number(res.headers.get('retry-after') || 0); const wait = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000; await new Promise((r) => setTimeout(r, wait)); return callWithRetry(url, init, attempt + 1); } ``` ## Designing under the limit - **Batch instead of looping.** Twenty requests in a `for` loop become one request that takes a list. - **Cache reads.** `ctx.redis` with a short TTL removes most repeat traffic. - **Poll slower than you think you need.** Ten seconds is usually indistinguishable from one for a human watching a screen. - **Do not retry a 429 immediately.** Back off. ## Next - [Public routes](/docs/edge/public-routes) — why they share a budget. - [Errors](/docs/edge/errors) — every code, including these three. --- # Errors URL: https://docs.fenaura.dev/edge/errors Section: Edge functions # Errors Errors come from two places: the runtime, before your code runs, and your own handler. The code tells you which. ## Runtime errors These are produced before your function executes. Your code cannot catch them and does not need to — the fix is always configuration or the caller. ### `401 SESSION_MISSING` No session on a private route. Every route is session-gated before your code runs, so this is returned instead of your handler being called. **Fix:** sign in, or send `Authorization: Bearer `. If the caller is a script, it must hold a real session; there is no way to skip the check on a private route. If the route should be open, declare it public deliberately — see [Public routes](/docs/edge/public-routes). ### `401 SESSION_INVALID` A session was presented but is not valid — expired, malformed, or unverifiable. **Fix:** sign in again to obtain a fresh session. ### `401 SESSION_CONFLICT` Two session cookies that disagree. **Fix:** clear cookies for the site and sign in again. Usually follows a domain change or holding a development and a production session simultaneously. ### `401 DEV_ACCESS_DENIED` A development URL was called without a dev key and without an active development session. **Fix:** send the `X-Fenaura-Dev-Key` header, or call the production URL. ### `403 EMAIL_NOT_VERIFIED` The project requires verified emails and this user has not verified. **Fix:** complete verification, or turn the requirement off while developing. ### `404 NOT_FOUND` No route matches, or no function is deployed. **Fix:** check the path against your routes, and check that the append is direct — `/api/notes`, not `/default/api/notes`. ### `429 PROJECT_THROTTLED` More than 1000 requests in 10 seconds for this project. **Fix:** batch, cache, or poll slower. On a public route this budget is shared by every visitor. ### `429 USER_THROTTLED` More than 200 requests in 10 seconds from one end user. **Fix:** this is nearly always a polling loop with no backoff. Fix the client. ### `429 PREVIEW_THROTTLED` Preview runs exceeded: 60 in a minute per developer, 30 per IP. **Fix:** wait. This limit exists because a preview run costs real compute. ## Your own errors Return the right status and a code, and log `ctx.requestId` so it can be found later: ```ts export default async (req: Request, ctx: FenauraContext) => { try { const body = await req.json(); if (!body?.title) { return Response.json( { error: 'A title is required', code: 'TITLE_REQUIRED', requestId: ctx.requestId }, { status: 400 }, ); } // ... } catch (e) { console.error('notes handler failed', ctx.requestId, e); return Response.json( { error: 'Something went wrong', code: 'INTERNAL', requestId: ctx.requestId }, { status: 500 }, ); } }; ``` Do not return a 200 with `{ error }` inside. A caller checking `res.ok` will treat the failure as a success, and the bug surfaces somewhere far away. ## Status codes to use | Status | When | |---|---| | 400 | The caller sent something malformed | | 401 | Not signed in (the runtime usually does this for you) | | 403 | Signed in, not allowed | | 404 | The thing does not exist | | 409 | Conflict, such as a duplicate | | 422 | Well-formed but semantically wrong | | 429 | Rate limited | | 500 | Your bug | ## A failed request leaves no trace An unauthenticated request to a private route produces no log line from your code, because your code did not run. When you are convinced a handler should have fired and see nothing, check the status code for `401` before reading the handler. ## Next - [Rate limits](/docs/edge/rate-limits) — the numbers behind the 429s. - [Authentication troubleshooting](/docs/auth/troubleshooting) — the 401s. --- # Storage URL: https://docs.fenaura.dev/storage Section: Storage # Storage Files live in buckets. A bucket belongs to the project, and every file in it is addressed by a path you choose. ## Uploading ```ts const { data, error } = await fenaura.storage .from('avatars') .upload(`users/${userId}.png`, file, { compress: true, onProgress: (percent) => console.log(`${percent}%`), }); ``` Uploads are chunked, so a large file resumes after a dropped connection instead of starting again. Chunks retry individually, which is what makes a 40 MB video over a phone connection behave. ### Options | Option | Default | Notes | |---|---|---| | `compress` | `false` | Images resize to a max of 1920px at quality 0.8; other files gzip | | `onProgress` | — | Receives a 0–100 number | | `upsert` | `false` | Overwrite an existing path | The upload cap is 50 MiB by default, and it applies after compression — so `compress: true` genuinely lets a bigger source photo through. ## Reading a file Three ways, and the choice is a security decision: ### Session-gated ```ts const { data } = await fenaura.storage.from('avatars').download(path); ``` The request carries the caller's session, so it works only for a signed-in end user. This is the default, and the right choice for anything private. ### Public ```ts const url = fenaura.storage.from('avatars').getPublicUrl(path); ``` Anyone with the URL can read it, signed in or not. Only for files you would put on a public web page — a logo, a marketing image. A public URL still needs a session for the API call that produces it, but the resulting link itself is open. ### Signed ```ts const { data } = await fenaura.storage .from('invoices') .createSignedUrl(path, 3600); // seconds ``` A time-limited URL that works without a session. Use it for "share this file" and for anything an outside service needs to fetch. Signed URLs are capped at seven days; a longer lifetime defeats the purpose. ## Listing ```ts const { data: files } = await fenaura.storage .from('avatars') .list({ prefix: `users/`, limit: 100 }); ``` `prefix` filters by path, which is why a structured path like `users/.png` pays off — it makes listing and access rules simple. ## Deleting ```ts await fenaura.storage.from('avatars').remove([`users/${userId}.png`]); ``` Deleting is immediate and permanent. There is no trash. ## Choosing an access model | The file | Use | |---|---| | Is public content, same for everyone | Public URL | | Belongs to one end user | Session-gated | | Must be fetched by an outside service | Signed URL | | Is sensitive and rarely needed | Session-gated, with a signed URL when sharing | ## Sizes and limits | Limit | Value | |---|---| | Upload cap | 50 MiB (after compression) | | Chunk size | 1 MiB | | Signed URL lifetime | Max 7 days | ## Next - [Uploading](/docs/storage/uploading) — chunking, retry, compression detail. - [Access control](/docs/storage/access) — the three read models in depth. --- # Uploading URL: https://docs.fenaura.dev/storage/uploading Section: Storage # Uploading An upload is split into 1 MiB chunks. Each chunk is sent, retried on failure, and acknowledged individually. The platform assembles the file when the last chunk arrives. ## The call ```ts const { data, error } = await fenaura.storage .from('videos') .upload(`raw/${videoId}.mp4`, file, { compress: false, upsert: false, onProgress: (percent) => setProgress(percent), }); if (error) { // the upload did not complete; nothing is stored } ``` Either the whole file lands or nothing does. A partial upload is not left behind for you to clean up. ## Why chunking matters On a good connection a single request is fine. On a phone moving between networks, a single request fails at 90 percent and starts again from zero. With chunks, the failed chunk is the only thing resent. You do not manage this. It is what the upload does. ## Progress `onProgress` receives a number from 0 to 100 across the whole file, not per chunk. Throttle your own rendering — a state update per byte will melt the UI: ```ts let last = 0; onProgress: (pct) => { if (pct - last >= 2 || pct === 100) { last = pct; setProgress(pct); } }, ``` ## Compression With `compress: true`: - **Images** are resized to a maximum of 1920px on the long edge and re-encoded at quality 0.8, keeping the format where it can. - **Other files** are gzipped. Compression happens before the cap is applied, so a 60 MiB photo that compresses to 4 MiB uploads without complaint. A 60 MiB zip that is already compressed does not fit, and returns an error naming the limit. Leave `compress` off for formats where re-encoding loses quality you care about — video, already-optimised images, and anything you will process yourself. ## Overwriting By default an upload to an existing path **fails**. That is deliberate: a second upload cannot silently replace the first. ```ts .upload(path, file, { upsert: true }) ``` Use `upsert: true` for a profile picture where the path never changes, and leave it off everywhere else. A versioned path (`users//.png`) makes the whole question disappear and gives you history for free. ## Somebody else uploaded the same path A concurrent upload to one path with `upsert: false` fails rather than racing. The second caller gets an error and can retry with a new path. This is a conflict report, not corruption. ## Verifying an upload After success, confirm what landed: ```ts const { data } = await fenaura.storage.from('videos').list({ prefix: `raw/${videoId}` }); ``` Check the size and the name. An upload that reported success but stored zero bytes is almost always a file object that was already consumed — a `Blob` from a form can only be read once if something else read it first. ## Next - [Access control](/docs/storage/access) — how the file is read back. - [Managing files](/docs/storage/managing) — list, move, delete. --- # Access control URL: https://docs.fenaura.dev/storage/access Section: Storage # Access control A file's access model is decided by how you produce the link. There are three, and each proves something different. ## Session-gated ```ts const { data } = await fenaura.storage.from('invoices').download(path); ``` The request carries the caller's session, so the platform knows who is asking. This is the right default for anything private. It proves *someone signed in*, not *this person may see this file*. If the file belongs to one user, the bucket path and your own check must enforce that: ```ts const path = `invoices/${userId}/${invoiceId}.pdf`; ``` Scoping by user id in the path means a guessed path is another user's file, which you then refuse. Do not rely on an unguessable name alone. ## Public ```ts const url = fenaura.storage.from('avatars').getPublicUrl(path); ``` The link works for anyone, forever, with no session. Use it only for content you would happily put on a public page. Two consequences people miss: - **The link does not expire.** Once it is out, it is out. - **Search engines can find it.** A public URL in an HTML page is indexable. That is correct for a logo and wrong for a tax document. ## Signed URLs ```ts const { data } = await fenaura.storage .from('invoices') .createSignedUrl(path, 3600); ``` A signed URL carries a time-limited signature. It works without a session for the number of seconds you choose, up to seven days. Producing it requires a session, so a signed URL is what you hand out to something that has no session: an email recipient, a webhook receiver, a third-party renderer. ```ts // Share a document with a client for an hour const { data } = await fenaura.storage.from('invoices').createSignedUrl(path, 3600); await sendEmail({ to: client.email, link: data.signedUrl }); ``` ## Choosing | Question | Answer | Use | |---|---|---| | Is it the same for everyone? | Yes | Public | | Does an outside system need to fetch it? | Yes | Signed | | Is it private to one end user? | Yes | Session-gated | | Is it private but occasionally shared? | Both | Session-gated, signed on share | ## Mistakes worth avoiding **A public bucket with private files in it.** Decide per bucket, and make the bucket name say which it is: `public-assets` versus `user-uploads`. **Signed URLs that live forever.** A seven-day link mailed to a customer is a permanent leak with a date on it. Use minutes for anything sensitive. **Trusting the path.** An unguessable name is obscurity, not access control. Sign it or gate it. **Checking in the frontend.** Hiding a download button is not access control. The file is one `curl` away. ## Next - [Managing files](/docs/storage/managing) — list and delete. - [Security and RLS](/docs/database/security) — the same reasoning for rows. --- # Managing files URL: https://docs.fenaura.dev/storage/managing Section: Storage # Managing files ## Listing ```ts const { data: files } = await fenaura.storage .from('avatars') .list({ prefix: 'users/', limit: 100, offset: 0 }); ``` `prefix` filters by the start of the path. There are no real directories — a path is a string, and a prefix is a string match. That is why consistent paths matter: ``` users/abc/avatar.png users/abc/invoices/2026-01.pdf ``` Listing `users/abc/` returns that user's files. Listing `users/` returns everything under any user. Without a scheme, you are listing the whole bucket and filtering in memory. ## Paginating ```ts const all: FileObject[] = []; let offset = 0; while (true) { const { data: page } = await fenaura.storage .from('documents') .list({ prefix: 'reports/', limit: 100, offset }); all.push(...page); if (page.length < 100) break; offset += 100; } ``` Stop when a page comes back short. An empty page means you are done. ## Downloading ```ts const { data: blob } = await fenaura.storage.from('avatars').download(path); ``` For a large file, prefer a URL over a download through the SDK: ```ts const { data } = await fenaura.storage.from('videos').createSignedUrl(path, 300); window.location.href = data.signedUrl; ``` The browser then streams the file directly, with its own progress and resume, instead of your code buffering it in memory. ## Deleting ```ts await fenaura.storage.from('avatars').remove([`users/${userId}/avatar.png`]); ``` `remove` takes an array. Deleting is immediate and permanent. Deleting a row that points at a file does not delete the file. Decide which side owns the lifecycle: - **Row owns the file.** Delete the file when the row is deleted, and nothing else creates a file without a row. - **File is independent.** Deleting the row leaves the file, and a cleanup job finds orphans. Pick one. Doing both halfway is how buckets fill with unreferenced files. ## A path scheme that works Put the owner first, then the kind, then the name: ``` // ``` It makes three things trivial: listing one owner's files, writing an access rule, and finding the file an error message is talking about. ## Next - [Access control](/docs/storage/access) — how each file gets read. - [Quotas](/docs/platform/quotas) — storage limits. --- # Email URL: https://docs.fenaura.dev/email Section: Email # Email A project can send email: account verification, password resets, welcome messages, and your own transactional or broadcast mail. ## The two kinds **Transactional** is one message to one person, triggered by something they did. It must arrive and it must not wait: a verification email delayed by ten minutes is a broken sign-up. **Broadcast** is one message to many people. It can be queued, it needs an unsubscribe path, and it is subject to bulk-sender rules. Mixing them is the classic mistake — sending a product update through the transactional path gets your domain flagged and your password resets stop arriving. ## Templates The project ships with templates for the flows that must work on day one: | Template | Sent when | |---|---| | Verification code | Signing up, if verification is required | | Password reset | A user requests a reset | | Magic link | Passwordless sign-in | | Welcome | After a user verifies | | Team invitation | You invite a collaborator | Edit them in the dashboard. Each holds variables such as `{{code}}`, `{{project_name}}` and `{{expires_in}}`, filled in when the mail is sent. Every template has a plain-text and an HTML body. Send both — a text body is not optional for your best inbox placement. ## Sending From an edge function, transactional mail goes out through the project's configured provider: ```ts await sendEmail({ to: 'ada@example.com', subject: 'Your receipt', html: '

Thanks for your order.

', text: 'Thanks for your order.', }); ``` The provider is the one configured in the project's email settings — Brevo, or your own SMTP. You do not put an API key in your code; the platform holds it. ## Deliverability Mail that lands in spam is mail that failed. Four things decide it, in this order: 1. **Authentication.** SPF, DKIM and DMARC records on your sending domain. Without them, a mailbox provider cannot tell your mail from a forgery. 2. **Reputation.** Sending history on that domain. A new domain sending a thousand messages on day one looks like a spammer. 3. **Content.** A plain-text part, a real unsubscribe link, no link shorteners, and nothing that reads like a phishing template. 4. **List hygiene.** Sending to addresses that do not exist destroys reputation faster than any other single cause. Set up domain authentication before you send more than a handful of messages. Retrofitting it after your domain is flagged is much harder than starting with it. ## Next - [Templates](/docs/email/templates) — editing and variables. - [Deliverability](/docs/email/deliverability) — SPF, DKIM, DMARC in practice. --- # Templates URL: https://docs.fenaura.dev/email/templates Section: Email # Templates Each template is the message for one flow. Editing it changes what the user receives; it does not change when it is sent. ## The variables These are available in the templates that support them: | Variable | Meaning | Available in | |---|---|---| | `{{code}}` | The verification or reset code | Verification, reset | | `{{email}}` | The user's address | All | | `{{project_name}}` | Your project's name, as the sender | All | | `{{expires_in}}` | How long the code lasts, in words | Code emails | | `{{verification_link}}` | The one-click link | Magic link, codes | | `{{tenant_name}}` | The organisation's name | Team invitation | | `{{role}}` | The role being granted | Team invitation | | `{{inviter}}` | Who sent the invitation | Team invitation | An unknown variable is left as written, so a typo appears literally in a user's inbox. Send yourself a test after editing. ## The variables you must keep A verification email without `{{code}}` cannot be used. A magic link without `{{verification_link}}` cannot be clicked. When you redesign a template, keep the variable that carries the payload — everything else is yours to change. ## Editing safely 1. Duplicate the template before a large change, so you can return to it. 2. Keep `{{code}}` or `{{verification_link}}`, whichever the flow uses. 3. Keep a plain-text body. It affects inbox placement, not just accessibility. 4. Send a test to a real mailbox you own, on a real domain. 5. Check it on a phone. Most codes are read on a phone, and a code that wraps across two lines is a support ticket. ## Testing a template Send it with a real code path rather than pasting a fake one. Sign up with a test address and confirm the mail arrives, the code works, and the link opens the right page. A template that renders beautifully but sends a dead link passes every visual check. ## HTML that survives a mail client Mail clients are not browsers. Keep to these: - Inline styles only. No `