# App Development - Functions, Database & API

Everything you need to build a backend on Gipity: serverless functions, databases, and REST APIs. This is the guide for the `web-fullstack` template (frontend in `src/` + functions + a `migrations/` database) and the `api` template (functions only, no frontend). Both are blank-but-wired: they deploy green as-is, so `gipity add` → `gipity deploy dev` is a working baseline before you write a line. Anything named `example` is a disposable placeholder - overwrite it or delete it, no ceremony. Deleting `functions/<name>.js` and redeploying deregisters that function and takes its endpoint down.

**Templates install real files - Read one before you change it.** `web-fullstack`/`api` write a full set of files (`gipity.yaml`, frontend, and more); they already exist on disk. To change one, read it first, then edit it. Don't treat template files as new.

## Workflow

For every new function:
1. Write the function in `functions/{name}.js`
2. Add it to `gipity.yaml` under `function_definitions` (name, auth, tables/fetch_domains)
3. Write tests in `tests/api/{name}.test.js`
4. Deploy: `gipity deploy dev`
5. Run tests: `gipity test`

**Strongly recommended: write a test file for every new function.** Use your judgment - skip tests only for trivial glue code (one-liners, obvious passthroughs), scratch/throwaway experiments, or when the user explicitly opts out. Default is: write tests. **Always deploy and test** after creating functions - don't ask permission, just do it.

**Where the testing details live:** load the [app-testing](app-testing.md) skill - the `test()` / `ctx.fn.call` contract, calling authenticated functions as a fixture user (`ctx.fn.callAs(ctx.users.alice, …)`), and [testing `auth: user` functions](app-testing.md#testing-auth-user-functions) for the signed-in happy path and cross-user isolation.

**In test files, do NOT `import` `node:test` or `node:assert`** - the harness provides `test()` and `assert` as globals. Writing `import { test } from 'node:test'` or `import assert from 'node:assert/strict'` will cause a duplicate-identifier SyntaxError.

**After every deploy, read the phase results.** Any phase with `status: warning` starts with "FIX" and means something is deprecated or shaped wrong - usually in `gipity.yaml`. Treat these as TODOs, not info: fix the underlying file and redeploy. `status: failed` phases block the deploy from completing correctly - fix before moving on.

---

## Writing Functions

Functions are JavaScript files in `functions/`. Each exports a default async handler:

```js
// functions/get-items.js
export default async function getItems(ctx, { db }) {
    const { category } = ctx.body;
    const { rows } = await db.query(
        'SELECT * FROM items WHERE category = $1', [category]
    );
    return rows;
}
```

**Arguments:**
- First: `ctx` = request context with `{ body, query, headers, method, auth }`
- Second: services object `{ db, fetch, secrets, env, console, jobs, guid, randomToken, storage, llm, image, tts, sound, music, video, transcribe, location, notify, email }` - the last ten are **app services** (see "App services from a function" below); each only works if declared in `gipity.yaml` `services: [...]`. `storage.delete(fileGuid)` removes an uploaded file's bytes + variants (see the `app-files` skill); like `db`/`jobs` it needs no declaration. `guid('prefix')` mints a complete, ready-to-use unique id - `prefix` + `_` + 12 crypto-random chars (e.g. `guid('RF')` → `RF_a7k2mq9pd3xe`); store and show it **whole** as the code, don't slice a suffix out of it to build your own (that's the trap - you get an empty/short fragment that collides). `randomToken(chars?)` mints crypto-random SECRETS (API keys, invite codes; 16-64 chars, default 40) - the runtime has no `crypto`, so this is the one entropy source.

**Return value** becomes `{ data: <your return> }` in the HTTP response. The `data` wrapper is **always present on 2xx**, so client code must read `json.data`, never `json` directly. On 4xx/5xx the body is `{ error: { code, message } }` instead.

**Imports must be relative** (`./helper.js`) - bare imports (`import _ from 'lodash'`) and Node built-ins (`fs`, `path`, `crypto`) are rejected; there is no `node_modules`.

**Share code across functions:** put the common helper in `functions/_lib/` and import it as `../_lib/<file>.js` from any function file - never copy it into each function or invent a `functions/lib/`. `_lib/` files bundle into every function at deploy. Full ES-module rules, multi-file functions (`functions/<name>/index.js` + helpers), `_lib` details, and kits that ship backend pieces: read [app-development-reference](app-development-reference.md).

### Request context (`ctx`)

```js
ctx.body     // request arguments land here - both Gipity.fn(name, body) and the test harness ctx.fn.call(name, body) send args in the POST body, so read every param (including a limit/page/filter) from ctx.body
ctx.query    // URL query string (?a=1) - only populated if you build a URL with one yourself; the SDK and test harness never use it, so don't reach for ctx.query expecting a passed-in arg
ctx.headers  // HTTP headers (safe subset - content-type, accept, user-agent, x-request-id, origin, referer, x-forwarded-for, x-real-ip)
             // x-forwarded-for is the original caller's IP chain - use ctx.headers['x-forwarded-for']?.split(',')[0].trim() to get the user's IP,
             // which you pass to the injected location.ip({ ip }) capability from a function (see app-location)
ctx.method   // HTTP method (always uppercase, e.g. "POST")
ctx.auth     // { userId, userGuid, displayName, role, via, apiKeyName, identity } - populated when auth_level is 'user' or 'member'
             // Key per-user data on userGuid (stable external id). userId is an internal numeric id - don't store it. See "Using auth".
```

### Calling functions from the client

Every Gipity template auto-injects the client SDK (`gipity.js`), which exposes `Gipity.fn(name, body?, opts?)` for calling functions. **Always use `Gipity.fn`** - it handles the `{ data: ... }` envelope unwrap, throws on non-2xx with the server's error message, and routes failures through the auto-monitor so they show up in `gipity logs app`. Hand-rolling `fetch` and reading `json.field` instead of `json.data.field` is the most common bug agents ship - it silently returns `undefined` on a 200, the save looks "rejected", and nothing reaches `gipity logs app` because the request succeeded on the wire.

```js
// Inside your entry script - no setup needed; the SDK reads data-app from its script tag.
const data = await Gipity.fn('list-items');
document.getElementById('count').textContent = `${data.items.length} items`;
```

If a 200 comes back with a shape you didn't expect (missing field, wrong type), report it explicitly so it lands in `gipity logs app`:

```js
const data = await Gipity.fn('add-post', { x, y, text });
if (!data?.post) {
  Gipity.captureError(new Error('add-post returned no post field'));
  return;
}
```

The `web-fullstack` template is the canonical reference. See [app-debugging](app-debugging.md) for everything else the auto-monitor catches and what it doesn't.

### Signaling failure / status codes

Two ways a function reports a problem, and they map to different HTTP statuses:

- **Validation / business errors → `return { error: 'name is required' }` on a 200.** The client reads `data.error` after `Gipity.fn` (the `{ data }` wrapper still applies). Use this for expected, recoverable conditions ("name is required", "item not found", "already claimed").
- **System / unexpected errors → `throw`.** The runtime catches it and returns a **500** `{ error: { code, message } }` (the envelope from the Return value note above). Use this for bugs and broken invariants - don't swallow them into `{ error }` on a 200, that hides real failures from the monitor.

There is **no first-class API for returning an arbitrary 4xx status** (e.g. 403/404/422) from app code - a function either returns 200 (with or without an `error` field) or throws → 500. Auth gating (401/403) is the platform's job: declare `auth: user` / `auth: member` in `gipity.yaml` and the runtime rejects unauthenticated callers before your handler runs. So: validate-and-`return { error }` for "the request was understood but can't be fulfilled", `throw` for "something is broken".

---

## Database

Functions read and write a per-app PostgreSQL database through the injected `db` helper - `db.query` plus `findOne`/`findMany`/`insert`/`update`/`delete`, `db.tx` transactions, and `db.describe`. Schema lives in `migrations/`; each function's table access is granted with `tables:` in `gipity.yaml` (below).

**Full reference → the [app-database](app-database.md) skill:** setup and PostgreSQL types, the `db` helpers and `db.tx`, per-invocation limits (and the `limits:` block to raise them), `RETURNING` / array-param / upsert patterns, views and `*` globs in `tables:`, and why schema must live in `migrations/` rather than a one-off `db_sql`.

---

## External HTTP (via `fetch`)

```js
export default async function getWeather(ctx, { fetch }) {
    const { zip } = ctx.body;
    const res = await fetch('https://api.example.com/weather?zip=' + zip);
    return await res.json();
}
```

**Limits:** max_fetches 10. 10s timeout. **1MB response limit** — watch this with real third-party APIs; a "give me everything" / `?detail=full` query easily blows the cap and the call throws. Ask for the narrowest response (paginate, pick fields, a smaller `mode=`) rather than fetching big and trimming.
**Security:** Only declared `fetch_domains` are allowed. Private IPs blocked (SSRF protection).
**Response shape:** `res` is WHATWG-style — `res.ok` (true only on 2xx), `res.status`, `res.statusText`, `res.headers.get('content-type')`, `await res.text()`, `await res.json()`. The usual guard works: `if (!res.ok) throw new Error(\`HTTP ${res.status}\`)`.

> **`fetch` is for third-party APIs only.** Don't `fetch` the platform's own services at `a.gipity.ai` (llm/image/tts/location/…) - the sandbox blocks every `*.gipity.ai` fetch, and `fetch_domains: ['a.gipity.ai']` does **not** unblock it. Call those through the injected app-service capabilities below instead.

## App services from a function (via `llm`, `image`, `tts`, …)

The platform's own services are injected as capabilities on the second argument - peers of `db` and `fetch` - so a function calls them server-to-server with no token and no `fetch`:

```js
export default async function summarize(ctx, { db, llm }) {
  const { rows } = await db.query('SELECT body FROM notes WHERE user_guid = $1', [ctx.auth.userGuid]);
  const { text } = await llm({ model: 'gpt-5.4-mini', messages: [
    { role: 'user', content: 'Summarize:\n' + rows.map(r => r.body).join('\n') },
  ]});
  await db.query('INSERT INTO summaries (user_guid, summary) VALUES ($1, $2)', [ctx.auth.userGuid, text]);
  return { summary: text };
}
```

```yaml
- name: summarize
  auth: user
  tables: ['notes', 'summaries']
  services: ['llm']      # required - declares which services this function may call
```

- Available: `llm` · `image` · `tts` · `sound` · `music` · `video` · `transcribe` · `location.ip()` / `location.geocode()` · `notify({ to, notification })` · `email({ to, subject, text })`. Each returns the same shape the HTTP service does (`{ text, usage, … }`, `{ url, … }`, …). See `app-llm`, `app-image`, `app-tts`, `app-audio`, `app-video`, `app-location`, `app-notify`, `email` for per-service params.
- **`email()`** sends from a deployed app (declare `services: ['email']`): `await email({ to, subject, text?, html?, replyTo?, fromName? })` → `{ sent, skipped, results }`. Platform-brokered and owner-billed (one credit per recipient sent); the From **address** is fixed to `gipity@gipity.ai` (the app sets only `fromName` + `replyTo`). This replaces hand-built outbox tables + cron workflows - just call it where the send decision is made. Verify after deploy with `gipity email test <to>`. Full guide: the `email` skill.
- **Declare each one in `services: [...]`** - calling an undeclared service throws. Calls are capped per invocation (`max_llm_calls` for `llm`, `max_service_calls` for the rest).
- **Calling a billed service from a function requires owner_pays.** Services now default to `user_pays` (safe-to-share - the app's users pay for their own usage). A function has no browser session for user_pays consent, so a `user_pays` service call from it **throws** - switch the service to `owner_pays` (via `project_settings`) to call it from a function, or make the call from the browser with a signed-in user. **Exceptions: `location`, `notify`, and `email`** are exempt from this gate - `location` is never credit-billed, and `notify`/`email` are always owner-billed (there's no "user pays to be notified/emailed" model), so all three work from a function regardless of billing mode.
- **`location.ip()` needs an explicit public IP** (e.g. `location.ip({ ip: ctx.headers['x-forwarded-for']?.split(',')[0].trim() })`) - the server can't see the user's IP on its own.

## Secrets (via `secrets`)

**Never hardcode an API key, password, or token in source.** Store it as a secret and read it at runtime — encrypted at rest (AES-256-GCM), write-only (values never leave the server), and never shipped to the browser.

```js
const apiKey = await secrets.get('OPENAI_API_KEY'); // null if unset
```

Set one from the CLI (UPPER_SNAKE_CASE names):

```bash
gipity secrets set OPENAI_API_KEY sk-...            # this project only
gipity secrets set OPENAI_API_KEY sk-... --account  # shared across ALL your projects
gipity secrets list          # names + masked previews (never values)
```

**Scope:** a secret is either **project**-scoped (this app) or **account**-scoped (shared across all your projects — set a key like `OPENAI_API_KEY` once and every app inherits it). `secrets.get(name)` resolves the **project** secret first, then falls back to the **account** secret of the same name, so a project can override the shared default.

## Environment Variables (via `env`)

```js
const mode = env.APP_MODE; // Read-only
```

## Logging (via `console`)

```js
console.log('Processing', { orderId });
console.warn('Rate limit approaching');
console.error('Failed', { error: err.message });
```

Captured in execution logs (`gipity fn logs <name>`).

## Using auth

`ctx.auth` is `{ userId, userGuid, displayName, role, via, apiKeyName, identity }`, populated when the function's `auth` is `user` or `member`. `identity` is set only for players who signed in to this app without a Gipity account (Steam or guest; see [steam-game](steam-game.md)): `{ provider: 'steam', id: '<SteamID64>' }` or `{ provider: 'guest', id }`, and `null` otherwise.

**Key all per-user data on `ctx.auth.userGuid`.** `userGuid` (e.g. `u_qpx38ujc`) is the stable external identity; `userId` is an internal numeric DB id that isn't stored in app data or returned to clients (same rule as everywhere else on the platform - external code uses guids, not ids). Store it in a `user_guid TEXT` column and filter every read/write by it. If `userGuid` is missing the caller isn't signed in - return the auth error:

```js
export default async function myTodos(ctx, { db }) {
    const userGuid = ctx.auth?.userGuid;
    if (!userGuid) return { error: 'Sign in required.' };
    const { rows } = await db.query(
        'SELECT * FROM todos WHERE user_guid = $1',
        [userGuid]
    );
    return { todos: rows };
}
```

---

## gipity.yaml - function permissions

Functions auto-deploy as public endpoints, and the deploy pipeline auto-adds an entry to `gipity.yaml` for any undeclared function. For the full manifest format, phases, and deploy flags see [deploy](deploy.md); this section covers only `function_definitions` (per-function permissions).

Declare in `function_definitions` only when you need to:
- Change auth level (`user`, `member`)
- Grant database access (`tables`)
- Allow external HTTP calls (`fetch_domains`) - **third-party APIs only** (not `a.gipity.ai`; see External HTTP above)
- Declare app services (`services: [...]`) - which platform services this function may call (llm/image/tts/sound/music/video/transcribe/location/notify/email). These are **injected as capabilities** on the second arg (`(ctx, { llm })`) and run server-to-server - no `fetch`, no token. An undeclared service throws; an unknown name fails the deploy. See "App services from a function" above and `app-llm` / `app-location`.

```yaml
- name: functions
  type: functions
  source: functions
  function_definitions:
    - name: get-items
      auth: public
      tables: [items]           # DB access
    - name: get-quote
      auth: public
      fetch_domains: [api.example.com]  # HTTP access
    - name: my-todos
      auth: user                # Requires login
      tables: [todos]
    - name: hello
      auth: public              # Auto-added by deploy (no special permissions)
```

---

## Writing Tests

Write a test file per function in `tests/api/<name>.test.js` and run `test_run`. `ctx.fn.call(name, params)` invokes your deployed function with the `{ data }` envelope unwrapped (read `result.field`; it throws on 4xx/5xx) against an **isolated, auto-reset test database** - no teardown to write. For `auth: user` functions, `ctx.fn.call` is the logged-out path; use `ctx.fn.callAs(ctx.users.alice, …)` for the signed-in happy path and cross-user isolation.

**Full reference → the [app-testing](app-testing.md) skill:** the `test()` / `ctx.fn.call` / `callAs` contract, the `node:test` import gotcha, `ctx.isTest` and what's isolated during a run, concurrency-safe fixtures, testing `auth: user` functions, and testing file serving with `ctx.upload`.

---

## Calling Functions

Public functions need no authentication:
```bash
curl -s -X POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/<name> \
  -H 'Content-Type: application/json' -d '{"key": "value"}'
```

**Always use the project GUID in URLs - never the slug.** This `POST .../api/<PROJECT_GUID>/fn/<name>` address IS the public URL to hand an external caller - a webhook, a device, another server. `deploy` prints the exact reachable URL for each public function (its `examples`), and `fn_manage list` shows each function's `endpoint`; copy that, don't guess a host.

`gipity fn call <name> [body]` is the quick CLI way to hit a deployed function - but note it runs **authenticated as the logged-in CLI user**, so calling a mutating `auth: user` function writes real rows owned by your account (real production state on prod). To test the logged-out path, call the endpoint without an auth header (plain `curl`). See [app-debugging](app-debugging.md#gipity-fn-call-name-body---test-a-function-directly).

## Auth Levels
- **public** - no auth needed, call directly
- **user** - any signed-in Gipity user; requires X-App-Token, X-Api-Key, or Authorization header (*load `app-auth` for details*)
- **member** - **you, the app owner** (plus anyone you add to the project). This is the owner-only gate: admin screens, settings, moderation, and decision queues.

**A script, cron, or agent calls your app with a project API key - never a hand-rolled one.** The platform already mints revocable, named, per-project keys; the caller sends `X-Api-Key: <key>` and passes the `user`/`member` gate with no browser and no login. Inside the handler `ctx.auth.via` is `'api_key'` (vs `'user'` for a signed-in person, `'anonymous'` for public) and `ctx.auth.apiKeyName` is that key's label - so "which rows did the script write?" is one column stamped from `ctx.auth`. **Never build your own api_keys table, token minting, or revoke endpoint** - see [app-auth](app-auth.md#api-keys-for-scripts-and-agents).

**"Nothing happens until I approve it" is `auth: member` + a status column.** Owner-decides-each-one apps (refund desk, moderation queue, booking requests) are one shape: a `public` function writes the request with `status = 'pending'`, an `auth: member` function lists the pending ones, and an `auth: member` function flips one to `approved`/`denied` (record who and when). Nothing auto-decides because only the member endpoint can write the status. Ping the owner that a decision is waiting from the create function with the injected `notify()` or `email()` service (`services: ['notify']`) - don't invent a settings row for a send path. Test it with `ctx.fn.callAs(ctx.users.owner, …)` (see [app-testing](app-testing.md)). The `approval_*` tools are **not** this - they pause the agent in chat/workflows, not a deployed app.

## Versioning, Management Commands & Rate Limits

Function versioning & rollback, management commands (`gipity fn list/logs/delete`), and API rate limits: read [app-development-reference](app-development-reference.md).

---

## On-demand actions: button → function vs. a Gipity workflow

When the user wants to "trigger" or "run on demand" something, pick by **where it runs**:

- **A button in the app** → frontend button calls `Gipity.fn(name, body)` → your function (the pattern above). Use this whenever the action needs the **browser**: the user's current GPS location, a file they picked, form input - anything client-side. "Record my location on a button" is this: only the browser has the device's location.
- **A Gipity workflow** (`workflow` skill) → server-side automation that runs with **no browser**, on a `trigger`: `schedule` (cron), `manual` (`gipity workflow run <name>`), `webhook`, or a `record.after_insert/update/delete` data change. Author it as `workflows/<name>.yaml` with a `workflows` deploy phase. Use it for recurring / time-driven / event-driven server jobs ("every morning", "when a row lands").

A workflow can't read the user's device or DOM, so don't model a browser-driven button as one.

---

## Related Skills

Load these for specific features:
- `app-development-reference` - the long tail of this guide: ES module rules, multi-file functions, `_lib` shared modules, backend-bearing kits, versioning & rollback, management commands, rate limits
- `app-database` - the per-app Postgres database: migrations, the `db` helper, transactions, table permissions
- `app-testing` - testing deployed functions: `ctx.fn.call` / `callAs`, the isolated test DB, fixtures
- `workflow` - scheduled / manual / webhook / data-change server automation (`workflows/*.yaml`)
- `realtime-scheduled-app` - recipe wiring functions + DB + realtime + a scheduled poster end-to-end
- `web-app-basics` - HTML/CSS/JS patterns and templates
- `deploy` - the deploy pipeline and `gipity.yaml` manifest
- `app-debugging` - inspect deployed pages, screenshots, function logs
- `app-auth` - user authentication (Sign in with Gipity)
- `app-realtime` - real-time multiplayer rooms and WebSocket
- `app-llm` - AI/LLM service for your app
- `app-image` - image generation (Gipity Image)
- `app-video` - video generation and understanding
- `app-audio` - sound effects, music, transcription
- `app-tts` - text-to-speech (Gipity Speech)
- `app-files` - file uploads (Gipity Storage, up to 30GB)
- `app-location` - user location & geocoding for deployed apps
- `3d-engine` - minimal 3D multiplayer template (Three.js + Rapier + Gipity Realtime)
- `3d-world` - playable 3D multiplayer app on top of 3d-engine
- `2d-game` - 2D games (Phaser 3)
