# Datasette JSON API — As Implemented This document describes the JSON API of this Datasette codebase (version `1.0a35`) as derived directly from the source code. It intentionally ignores the existing `docs/` directory: every claim below is based on the route table in `datasette/app.py` (`Datasette._routes()`, app.py:2507-2767) and the view implementations in `datasette/views/`. ## Contents - [Cross-cutting behavior](#cross-cutting-behavior) - [Instance endpoints](#instance-endpoints) - [Database endpoints](#database-endpoints) - [Table and row read endpoints](#table-and-row-read-endpoints) - [The write API](#the-write-api) - [Stored (canned) queries API](#stored-canned-queries-api) - [Authentication and tokens](#authentication-and-tokens) - [Appendix: registered actions (permissions)](#appendix-registered-actions-permissions) --- ## Cross-cutting behavior ### URL formats and content negotiation - Most read endpoints are registered with an optional format suffix: `/(...)(\.(?Pjson))?$`. The bare path returns HTML; the `.json` extension returns JSON. - Table, row and query routes accept any `\w+` format extension; formats other than the built-in `html`, `json`, `csv`, `blob` must be provided by a plugin via `register_output_renderer`, otherwise the request 404s. - HTML responses include a `Link: <...>; rel="alternate"; type="application/json+datasette"` header pointing at the `.json` variant (views/base.py:141-159), unless the view opts out with `has_json_alternate = False`. - Database, table, row and query names in paths are **tilde-encoded** (a percent-encoding variant using `~` as the escape character; utils/__init__.py `_TILDE_ENCODING_SAFE`). Multi-column primary keys in row URLs are comma-separated. - JSON responses are always compact `json.dumps` output serialized by `CustomJSONEncoder`; there is no pretty-printing query parameter. Binary values are serialized as `{"$base64": true, "encoded": "..."}`. - Success content type: `application/json; charset=utf-8` (`_shape=array&_nl=on` responses use `text/plain`). ### Success envelope and stability marker Every JSON endpoint that returns an object includes `"ok": true` on success. `JsonDataView` injects it automatically for dict responses (views/special.py); the homepage, jump, schema, permission-debug and autocomplete views add it explicitly. The former top-level-array endpoints (`/-/plugins`, `/-/databases`, `/-/actions`) now return objects wrapping their arrays (`{"ok": true, "plugins": [...]}` etc.). JSON endpoints that are **not part of the documented API** include a marker key (`UNSTABLE_API_MESSAGE` in utils/__init__.py): ```json "unstable": "This API is not part of Datasette's stable interface and may change at any time" ``` Currently: the homepage (`/.json`, `/-/.json`), `/db/-/queries/analyze`, `POST /db/-/queries/store`, `/db//-/definition`, `/db/-/query/parameters`, `/db/-/execute-write/analyze` and the `POST /-/permissions` playground response. ### Error shape (canonical) Every JSON error response uses one canonical shape, built by `error_body()` (utils/__init__.py): ```json { "ok": false, "error": "all messages joined with '; '", "errors": ["message", "..."], "status": 404 } ``` - `errors` is a list of one or more message strings (multi-message validation errors, e.g. per-row insert errors, list them all). - `error` is the messages joined with `"; "`. - `status` always matches the HTTP status code. The shape is produced by four code paths, all delegating to `error_body()`: 1. **Exception handler** (handle_exception.py) — `NotFound`, `DatasetteError`, `BadRequest` etc. on `.json` paths. `DatasetteError` `error_dict` context keys are merged in; the legacy `title` key is no longer emitted in JSON (it survives in the HTML error template context). 2. **The `_error()` helper** (views/base.py:183-184) — the write API, stored-query API, execute-write and permission-denied paths. 3. **JSON renderer errors** (renderer.py) — SQL errors on table/query endpoints return HTTP 400 with the canonical keys **plus** the context keys of the response it could not produce: ```json {"ok": false, "error": "no such table: x", "errors": ["no such table: x"], "status": 400, "rows": [], "truncated": false} ``` Invalid `_shape=` values and `_shape=object` misuse (on queries or pk-less tables) also return canonical 400 errors. 4. **Permission debug endpoints** (`/-/allowed`, `/-/rules`, `/-/check`, POST `/-/permissions`) — canonical shape (previously bare `{"error": ...}` objects). Method-not-allowed responses return HTTP 405 with the canonical shape when the path ends in `.json` or the request content type is `application/json`; plain text otherwise (views/base.py). **`Forbidden` handling:** when a view raises `Forbidden` (e.g. via `ensure_permission`), the default `forbidden()` plugin hook returns the canonical JSON error with status 403 when the path ends in `.json` or the request carries an `Accept: application/json` / `Content-Type: application/json` header; other requests get an HTML error page (forbidden.py). Endpoints that check permissions themselves return `_error(..., 403)` JSON directly. ### CORS When Datasette is started with `--cors`, responses gain (utils/__init__.py:1297-1302): ``` Access-Control-Allow-Origin: * Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Expose-Headers: Link Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS Access-Control-Max-Age: 3600 ``` ### CSRF / cross-origin protection Datasette uses header-based cross-origin protection (`CrossOriginProtectionMiddleware`, csrf.py:67-178) rather than CSRF tokens for API calls. For non-GET/HEAD/OPTIONS requests: 1. Requests carrying `Authorization: Bearer ...` **and no `Cookie` header** bypass the check entirely (csrf.py:98-110). 2. Otherwise `Sec-Fetch-Site` must be `same-origin` or `none`; other values → 403. 3. If neither `Sec-Fetch-Site` nor `Origin` is present (curl, API clients), the request passes. 4. Fallback: `Origin` must exactly match the request scheme/host/port → else 403. Plain JSON API clients (no cookies, no browser headers) are never blocked; `Content-Type: application/json` itself plays no role in the CSRF decision. ### Settings that govern the API From `SETTINGS` (app.py:197-287): `default_page_size` (100), `max_returned_rows` (1000), `max_insert_rows` (100), `sql_time_limit_ms` (1000), `default_facet_size` (30), `facet_time_limit_ms` (200), `allow_facet` (true), `allow_download` (true), `allow_signed_tokens` (true), `default_allow_sql` (true), `max_signed_tokens_ttl` (0), `default_cache_ttl` (5), `allow_csv_stream` (true), `max_csv_mb` (100), `force_https_urls` (false), `trace_debug` (false), `base_url` ("/"). ### The JSON renderer: `_shape`, `_nl`, `_json`, `_json_infinity` `json_renderer` (renderer.py:31-126) processes `.json` output for table, row and query views (but **not** for the instance/database/debug endpoints, which build JSON directly): - **`_shape`** (default `objects`): - `objects` — `{"ok": true, "rows": [{col: val}, ...], "truncated": false, ...}` - `arrays` — same envelope, each row a list of values - `array` — response body is a bare JSON array of row objects - `arrayfirst` — bare JSON array of the first column's values - `object` — table views only: an object keyed by primary-key string. On queries or tables without primary keys: a canonical 400 error (`_shape=object is only available on tables` / `_shape=object not available for tables with no primary keys`). - anything else — canonical HTTP 400 error `Invalid _shape: x` - **`_nl=on`** — with `_shape=array` only: newline-delimited JSON, `text/plain`. - **`_json=COLUMN`** (repeatable) — parse that column's string values with `json.loads` so they nest as JSON; parse failures leave the value unchanged. - **`_json_infinity=1`** — preserve `Infinity`/`-Infinity`; by default they are replaced with `null`. - `columns` is stripped from dict-shaped output unless `?_extra=columns` was requested (renderer.py:110-113). - If a SQL error occurred, `_shape` is ignored, HTTP status is 400 and the envelope carries the canonical error keys alongside `rows`/`truncated`. ### The `?_extra=` system Table, row and query JSON responses support `?_extra=` (repeatable and/or comma-separated, extras.py:9-14) to add keys to the response. Extras are scope-registered (`ExtraScope.TABLE` / `ROW` / `QUERY`) and only **public** extras are available over JSON (extras.py:73-92). Unknown extra names (and internal HTML-only names) on data formats return 400 `Unknown _extra: `; HTML pages ignore them. The available names per scope are listed with the relevant endpoints below. --- ## Instance endpoints Most of these are implemented with `JsonDataView` (views/special.py:30-79): GET-only; bare path renders an HTML page (`show_json.html`), `.json` returns the data; permission defaults to `view-instance` and denial raises `Forbidden` → **HTML** 403 page. ### GET / Routes: `/(\.(?Pjsono?))?$` and `/-/(\.(?Pjsono?))?$` (app.py:2517-2518); `/-` permanently redirects to `/-/`. `IndexView` (views/index.py:22-189). `GET /.json`, `/.jsono` and `/-/.json` return JSON. - **Permission:** `view-instance` (denied → 403). Databases and tables are further filtered by `view-database` / `view-table` for the actor. - **Parameters:** `_sort=relationships` sorts each database's truncated table list by foreign-key relationship count. - **JSON response** (index.py:147-161) — includes `ok: true` plus: - `databases` — a **list** of database objects (undocumented API, subject to change). Each item: `name`, `hash` (or null), `color`, `path`, `tables_and_views_truncated` (up to 5 items: `name`, `columns`, `primary_keys`, `count` (int or null), `hidden`, `fts_table`, `num_relationships_for_sorting`, `private`; view items are just `{"name", "private"}`), `tables_and_views_more` (bool), `tables_count`, `table_rows_sum`, `show_table_row_counts`, `hidden_table_rows_sum`, `hidden_tables_count`, `views_count`, `private`. - `metadata` — instance metadata object. ### GET /-/versions(.json) `JsonDataView` over `Datasette._versions` (app.py:2548-2551, 2171-2245). Permission `view-instance`. No parameters. Response keys: `python` (`{version, full}`), `datasette` (`{version}` plus optional `note`), `asgi` (`"3.0"`), `uvicorn` (string or null), `sqlite` (`{version, fts_versions, extensions, compile_options}`; `extensions` includes `json1` and optionally `spatialite`), `pysqlite3` (only when running under pysqlite3). ### GET /-/plugins(.json) app.py:2552-2557, `Datasette._plugins` (app.py:2247-2266). Permission `view-instance`. - **Parameters:** `?all=1` — include Datasette's built-in default plugins (filtered out by default). - **Response:** `{"ok": true, "plugins": [...]}` — each plugin is `{"name", "static", "templates", "version", "hooks"}`, sorted by name. ### GET /-/settings(.json) app.py:2558-2561. Permission `view-instance`. No parameters. Returns a flat object mapping every setting name (see [Settings](#settings-that-govern-the-api)) to its effective value. ### GET /-/config(.json) app.py:2562-2565. Permission `view-instance`. No parameters. Returns the full `datasette.yaml` configuration dict passed through `redact_keys(config, ("secret", "key", "password", "token", "hash", "dsn"))` (app.py:2502-2505) — any dict key containing one of those substrings has its value replaced by `"***"` (utils/__init__.py:1532-1556). ### GET /-/threads(.json) app.py:2566-2569, `Datasette._threads` (app.py:2268-2285). Permission **`permissions-debug`** (exposes runtime internals). No parameters. Response: `num_threads`, `threads` (list of `{name, ident, daemon}`), `num_tasks`, `tasks` (asyncio task repr strings). When the `num_sql_threads` setting is 0 the response is exactly `{"num_threads": 0, "threads": []}`. ### GET /-/databases(.json) app.py:2570-2573, `Datasette._connected_databases` (app.py:2157-2169). Permission `view-instance`. No parameters. Response: `{"ok": true, "databases": [...]}` — each database is `{"name", "route", "path", "size", "is_mutable", "is_memory", "hash"}`. Only databases the actor is allowed to `view-database` are listed. ### GET /-/actor(.json) app.py:2574-2579, registered with `permission=None` — **accessible to any request including anonymous**. No parameters. Response: `{"ok": true, "actor": {...}}` or `{"ok": true, "actor": null}` (app.py:2287-2288). ### GET /-/actions(.json) app.py:2580-2589. Permission **`permissions-debug`**. No parameters. Response: `{"ok": true, "actions": [...]}` — each action is `{"name", "abbr", "description", "takes_parent", "takes_child", "resource_class", "also_requires"}`, sorted by name (app.py:2290-2304). ### GET /-/auth-token `AuthTokenView` (app.py:2590-2593, views/special.py:198-217). GET only, no `.json` variant, HTML/redirect only. - **Parameter:** `token` — the one-time secret printed by `datasette --root`. - Match → invalidates the token, sets the signed `ds_actor` cookie to `{"id": "root"}` and 302-redirects to the homepage. Mismatch or reuse → `Forbidden` → 403 HTML. ### GET/POST /-/create-token `CreateTokenView` (app.py:2594-2597, views/special.py:727-856). **HTML form endpoint only — there is no JSON request/response mode in this codebase** (`has_json_alternate = False`; the POST body must be form-encoded, a JSON content type raises `BadRequest` → 400). - **Gates** (each failure → `Forbidden` → 403): `allow_signed_tokens` must be on; request must have an actor with an `id`; the actor must not itself be token-derived. - **POST fields:** `expire_type` (`""`/`minutes`/`hours`/`days`), `expire_duration` (positive int), plus restriction checkboxes named `all:`, `database::`, `resource:::`. - **Response:** HTML page containing the new `dstok_` token. - Programmatic alternatives: `datasette create-token` CLI or `datasette.create_token()`. ### GET /-/api `ApiExplorerView` (app.py:2598-2601, views/special.py:859-1020). HTML API explorer, GET only. Permission `view-instance` (403 on denial). ### GET /-/jump(.json) `JumpView` (app.py:2602-2605, views/special.py:1023-1201). The route allows an optional `.json` suffix but the view **always returns JSON**. - **Permission:** none checked directly; results are filtered via `allowed_resources_sql` for the current actor (default items come from the `jump_items_sql` plugin hook). - **Parameter:** `q` — whitespace-split terms matched as a case-insensitive `%term1%term2%` LIKE pattern. - **Response:** `{"ok": true, "matches": [...], "truncated": bool}`; each match: `name`, `url`, `type` (`database`/`table`/`view`/`query`/plugin-defined), `description`, optional `display_name`. Capped at 100 matches. ### GET /-/schema(.json|.md) `InstanceSchemaView` (app.py:2610-2613, views/special.py:1257-1293). - **Permission:** no explicit check; only databases the actor can `view-database` are included (others silently omitted). - **Formats:** no extension → HTML; `.json` → `{"ok": true, "schemas": [{"database": name, "schema": "..."}]}`; `.md` → `text/markdown` rendering. ### GET/POST /-/logout `LogoutView` (app.py:2614-2617, views/special.py:220-238). HTML endpoint. GET renders a confirmation page (or redirects if anonymous); POST deletes the `ds_actor` cookie and 302-redirects to `/`. ### GET/POST /-/permissions `PermissionsDebugView` (app.py:2618-2621, views/special.py:241-295). No `.json` route. Both methods require `view-instance` **and** `permissions-debug` (403 on denial). - **GET** — HTML permission-check log; `?filter=all|exclude-yours|only-yours`. - **POST** — form-encoded `actor` (JSON string), `permission`, optional `resource_1`, `resource_2`; returns **JSON** `{"action", "allowed", "resource": {"parent", "child", "path"}}` plus `actor_id` when present. Errors: unknown action → 404; child without parent → 400 (both canonical error shape). ### GET /-/allowed(.json) `AllowedResourcesView` (app.py:2622-2625, views/special.py:298-460). Bare path always renders the HTML form; `.json` returns JSON. - **Permission:** none — reports the **current actor's own** allowed resources. Items gain a `reason` field if the actor also holds `permissions-debug`. - **Parameters:** `action` (required; missing → 400 canonical error, unknown → 404), `parent`, `child` (requires `parent`), `page` (default 1), `page_size` (default 50, silently capped at 200). - **Response:** `{"action", "actor_id", "page", "page_size", "total", "items": [{"parent", "child", "resource"}]}` with optional `next_url` / `previous_url`. ### GET /-/rules(.json) `PermissionRulesView` (app.py:2626-2629, views/special.py:463-584). Permission `view-instance` **and** `permissions-debug`. Parameters and error shapes as `/-/allowed`. Response items: `{"parent", "child", "resource", "allow" (1|0), "reason", "source_plugin"}`. ### GET /-/check(.json) `PermissionCheckView` (app.py:2630-2633, views/special.py:633-662). Permission `permissions-debug`. Parameters `action` (required), `parent`, `child`. Checks the **current request's actor**; response `{"action", "allowed", "resource": {...}}` plus `actor_id`. ### GET/POST /-/messages `MessagesDebugView` (app.py:2634-2637, views/special.py:703-724). HTML debug tool for flash messages; permission `view-instance`; POST is form-encoded (`message`, `message_type` = INFO/WARNING/ERROR/all) and 302-redirects. ### GET /-/allow-debug `AllowDebugView` (app.py:2638-2641, views/special.py:665-700). GET only, HTML only, **no permission required**. Parameters `actor` and `allow` (JSON strings); renders the result of `actor_matches_allow()` in the page. ### GET /-/patterns Pattern portfolio page (app.py:2642-2645). HTML only; not part of the JSON API. ### GET /-/debug/autocomplete `AutocompleteDebugView` (app.py:2646-2649, views/special.py:94-195). HTML debug page for the table autocomplete API; permission `view-instance` plus `view-table` when `?database=&table=` are supplied. --- ## Database endpoints ### GET /\.db Downloads the raw SQLite file. Route → `database_download` (app.py:2650-2653; views/database.py:533-570). - **Permission:** `view-database-download` (denied → `Forbidden` → 403 HTML). - **Other gates:** unknown database → 404 `"Invalid database"`; in-memory database → 404; `allow_download` off **or** mutable database → `Forbidden("Database download is forbidden")`; no file path → 404. - **Response:** streamed `application/octet-stream` with a `content-disposition` attachment; immutable databases with a known hash set `Etag` and honor `If-None-Match` → 304. ### GET /\(.json) `DatabaseView` (app.py:2654-2657; views/database.py:71-277). Only `html` and `json` formats are accepted; any other extension → 404 `"Invalid format: ..."`. - **Permission:** `view-database` via `check_visibility` (denied → `Forbidden` → 403 HTML). Table/view listings are filtered by `view-table`; stored queries by `view-query`. - **Parameters:** - `?sql=` — non-blank value 302-redirects to `//-/query?...` preserving the query string and format. - No `?_extra=` and no `_shape` support — the JSON is built directly and returned via `Response.json`, bypassing the JSON renderer (views/database.py:189-212). - **JSON response** (all keys always present): - `ok` — always `true` - `database` — name; `private` — bool; `path` — URL path; `size` — bytes - `tables` — list (includes hidden tables), each: `name`, `columns` (names), `primary_keys`, `count` (int or null, time-boxed), `count_truncated` (bool — count is a capped lower bound), `hidden`, `fts_table`, `foreign_keys` (`{incoming: [...], outgoing: [...]}` of `{other_table, column, other_column}`), `private` - `hidden_count` — number of hidden tables - `views` — list of `{name, private}` - `queries` — **up to 5** stored queries (canonical stored-query objects, see the stored-queries section); `queries_more` (bool); `queries_count` (total visible) - `allow_execute_sql` — bool for this actor - `table_columns` — `{table: [columns]}`, empty `{}` unless `allow_execute_sql` (views map to `[]`) - `metadata` — database metadata dict ### GET /\/-/query(.json) — arbitrary SQL `QueryView` (app.py:2691-2694; views/database.py:573-1130). The same class also executes stored queries dispatched from the table route (see stored queries section). - **Permission:** `execute-sql` on the database via `check_visibility` (denied → `Forbidden` → 403 HTML). - **Parameters:** - `sql` — SQL to run. Must pass `validate_sql_select` (utils/__init__.py:345-354): after stripping `--` comment lines it must start with `select`, `with` or an `explain` variant, and must not contain `pragma` (except allowlisted `pragma_*()` table-valued functions). Failure → 400 `DatasetteError` titled `"Invalid SQL"` → JSON `{"ok": false, "error": "Statement must be a SELECT", "status": 400, "title": "Invalid SQL"}`. - Any other `name=value` pair supplies the `:name` named parameter; missing parameters default to `""`. Names starting with `_` are excluded. - `_timelimit` — per-request SQL time limit in ms. - `_shape`, `_nl`, `_json`, `_json_infinity` — see the JSON renderer section. - `_extra` — QUERY-scope extras: `columns`, `debug`, `request`, `render_cell`, `query` (`{"sql", "params"}`), `metadata`, `database`, `database_color`, `private`, `extras`. - **Response** (default shape): `{"ok": true, "rows": [{col: val}, ...], "truncated": false}` plus any requested extras. `truncated: true` when the result hit `max_returned_rows`. - **Errors:** - SQLite errors (e.g. `no such table`) are **not** raised — they surface as HTTP 400 `{"ok": false, "error": "", "rows": [], "truncated": false}`. - Time limit → 400 titled `"SQL Interrupted"` (the `error` value contains an HTML fragment). - `?sql=` omitted or blank → 400 `"?sql= is required"` for all data formats (`.json`, `.csv`, plugin formats). The HTML page remains the SQL editor. - `.csv` streams CSV; unknown extensions → 404. ### GET /\/-/query/parameters `QueryParametersView` (app.py:2687-2690; views/stored_queries.py:26-51). - **Permission:** `execute-sql` → 403 JSON `{"ok": false, "errors": ["Permission denied: need execute-sql"]}`. - **Parameters:** only `sql` (default `""`); any other key → 400 `"Invalid keys: ..."`. - **Response:** 200 `{"ok": true, "parameters": ["name1", ...]}`. SQL with a parameter beginning `_` → 400 `"Magic parameters are not allowed"`. - Responses carry `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`. ### POST /\/-/create `TableCreateView` (app.py:2658; views/table_create_alter.py:785-962). GET → 405. Body is parsed as JSON regardless of content type; invalid JSON → 400 `{"ok": false, "errors": ["Invalid JSON: ..."]}`. - **Permissions** (all denials → 403 canonical error JSON, all checked at the **database** level): - `create-table` — always required (`["Permission denied"]`) - `insert-row` — if `rows`/`row` provided (`need insert-row`) - `update-row` — if `replace: true` (`need update-row`) - `alter-table` — if `alter: true` on an **existing** table (`need alter-table`); when the table does not exist yet and rows are supplied, alter is enabled automatically. - **Request schema** (pydantic `CreateTableRequest`, extra keys forbidden → 400 `"Invalid keys: a, b"`): - `table` (required) — must match `^(?!sqlite_)[^\n]+$` - `rows` (list of objects) / `row` (single object) — mutually exclusive - `columns` — list of `{name, type, fk_table, fk_column, not_null, default, default_expr}`; mutually exclusive with `rows`/`row`; `type` one of `text`/`integer`/`float`/`blob` (default `text`); `default` and `default_expr` mutually exclusive; `default_expr` one of `current_timestamp`, `current_date`, `current_time`, `current_unixtime`, `current_unixtime_ms`. At least one of `columns`/`rows`/`row` required. - `pk` (string) / `pks` (list) — mutually exclusive. For an existing table a differing pk → 400 `"pk cannot be changed for existing table"`. - `ignore` / `replace` (bools) — mutually exclusive; require `row`/`rows` and `pk`/`pks`. - `alter` (bool) — add missing columns when inserting into an existing table. - **Success** — **201**: ```json {"ok": true, "database": "...", "table": "...", "table_url": "https://.../db/table", "table_api_url": "https://.../db/table.json", "schema": "CREATE TABLE ...", "row_count": 2} ``` `row_count` only when rows were inserted. Write failures → 400 `{"ok": false, "errors": [""]}`. Emits `create-table` / `insert-rows` / `alter-table` events. ### POST /\/-/execute-write `ExecuteWriteView` (app.py:2679-2682; views/execute_write.py:236-476). GET on the same path renders an HTML form (requires `execute-write-sql`). - **Permission (POST):** `execute-write-sql` → 403 `{"ok": false, "errors": ["Permission denied: need execute-write-sql"]}`; immutable database → 403 `["Database is immutable"]`. - **Per-statement permissions:** the SQL is analyzed (`decision_for_write_sql_operation`, write_sql.py:63-189) and each operation must pass: | Operation | Requirement | |---|---| | `select` / internal ops / function calls | ignored | | read of a table | `view-table` on that table | | `insert` or `update` | **all of** `insert-row`, `update-row`, `delete-row` on the table | | `delete` | `delete-row` | | `create table` | `create-table` on the database | | `alter table`, `create index`, `drop index` | `alter-table` on the table | | `drop table` | `drop-table` | | `VACUUM`, virtual-table writes, shadow-table writes | rejected outright (403) | | statements touching attached databases | rejected (403) | - **Body:** JSON (`{"sql": ..., "params": {...}}` — only those two keys) or form-encoded (`sql` plus one field per parameter, `_sql_param_` prefix stripped). Validation errors (400): `"SQL is required"`, `"params must be a dictionary"`, `"Unknown parameters: a, b"`, `"Magic parameters are not allowed"`, `"Could not analyze query: ..."`, `"Use /-/query for read-only SQL; this endpoint only executes writes"`. - **JSON is returned when** the body was JSON, `Accept: application/json`, or a truthy `_json` field is present; otherwise HTML. - **Success** — 200: ```json {"ok": true, "message": "Query executed, 1 row affected", "rowcount": 1, "rows": [], "truncated": false, "analysis": [{"operation": "insert", "database": "db", "table": "t", "required_permission": "insert-row, update-row, delete-row", "source": null}]} ``` `rows` is populated by `RETURNING` clauses. SQLite errors → 400 `{"ok": false, "errors": [""]}`. Anti-framing headers on all responses. ### GET /\/-/execute-write/analyze `ExecuteWriteAnalyzeView` (app.py:2675-2678; views/execute_write.py:479-507). - **Permission:** `execute-write-sql` → 403 `errors` JSON. - **Parameters:** only `sql` allowed (else 400 `"Invalid keys: ..."`). - **Response** — 200 even when analysis fails (`ok: false` in body): `{"ok", "parameters", "analysis_error", "analysis_rows": [{operation, database, table, required_permission, source, allowed}], "execute_disabled", "execute_disabled_reason"}`. `allowed` is a per-actor permission check result (true/false/null). ### GET /\/-/foreign-key-targets `DatabaseForeignKeyTargetsView` (app.py:2659-2662; views/table_create_alter.py:965-1005). - **Parameter:** `table` (optional) — only used for the permission check. - **Permission:** `create-table` on the database, **or** `alter-table` on `?table=` when it names an existing table. Neither → 403 `{"ok": false, "errors": ["Permission denied: need create-table"]}`. - **Response:** 200 `{"ok": true, "database": "...", "targets": [{"fk_table", "fk_column", "type"}]}` — every non-hidden table with exactly one primary-key column; `type` is the pk's SQLite type affinity. ### GET /\/-/schema(.json|.md) `DatabaseSchemaView` (app.py:2683-2686; views/special.py:1296-1329). - **Permission:** `view-database` (denied → `Forbidden` → 403 HTML). - **Unknown database** → 404; for `.json`: `{"ok": false, "error": "Database not found"}`. The permission check runs first, so unauthorized actors cannot probe for database existence. - **Responses:** `.json` → 200 `{"ok": true, "database": "", "schema": ""}` (concatenated `sqlite_master.sql` joined with `;\n`); `.md` → `text/markdown`; no extension → HTML. --- ## Table and row read endpoints ### GET /\/\.json Route `r"/(?P[^\/\.]+)/(?P
[^\/\.]+)(\.(?P\w+))?$"` → `table_view` (app.py:2711-2714; views/table.py:1670). Serves both tables and SQL views. GET/HEAD only — POST returns a plain-text 405. If the name is neither a table nor a view but matches a stored query, the request is dispatched to `QueryView` (views/table.py:1703-1712). **Permission:** `view-table` via `check_visibility`; denial raises `Forbidden` → **HTML** 403 page even for `.json`. Unknown table → `TableNotFound` → 404 (JSON error shape for `.json` paths). **Default JSON keys** (views/table.py:2308-2332 + renderer): | Key | Meaning | |---|---| | `ok` | `true` when data was retrieved without error | | `next` | pagination token string, or `null` on the last page | | `next_url` | absolute URL of the next page, or `null` on the last page | | `rows` | list of row objects `{column: value}` (default `_shape=objects`) | | `truncated` | always present; `false` for table pages | `columns` is computed but removed unless `?_extra=columns` was requested. When there is a next page the response carries a `Link: ; rel="next"` header (views/table.py:1911-1912). **`?_extra=` options** (TABLE scope; registry views/table_extras.py:1197-1235; unknown names silently ignored): | `_extra=` | Returns | |---|---| | `count` | total matching-row count, computed with a `limit 10001` subquery so it caps at 10001; `null` with `_nocount` or on count timeout. Requesting `count` implicitly includes `count_truncated` | | `count_truncated` | `true` when `count` hit the counting limit (the real count is at least the reported value) | | `count_sql` | the SQL used for the count | | `facet_results` | `{"results": {name: facet}, "timed_out": [...]}`; each facet: `{name, type, hideable, toggle_url, results: [{value, label, count, toggle_url, selected}], truncated}` | | `facets_timed_out` | facet names that exceeded `facet_time_limit_ms` | | `suggested_facets` | `[{name, toggle_url, (type)}]`; empty when suggestion is disabled or paginating | | `human_description_en` | English description of filters + sort | | `next_url` | absolute URL of the next page or `null` | | `columns` | column names of the returned rows | | `all_columns` | all table columns regardless of `_col`/`_nocol` | | `primary_keys` | pk column names (empty for rowid tables and views) | | `display_columns` | HTML-oriented column metadata | | `render_cell` | per-row plugin-rendered HTML strings | | `debug` | `{url_vars, resolved, nofacet, nosuggest}` — explicitly unstable | | `request` | `{url, path, full_path, host, args}` | | `query` | `{sql, params}` of the main query | | `column_types` | `{column: {type, config}}` assigned column types | | `set_column_type_ui` | UI helper, `null` unless actor has `set-column-type` | | `metadata` | table metadata dict including column descriptions | | `extras` | self-describing list of all available extras | | `database`, `table`, `database_color` | identity/display values | | `renderers` | `{format_name: url}` of formats that can render this data | | `custom_table_templates` | template lookup list | | `sorted_facet_results` | facets as a display-ordered list | | `table_definition` | `CREATE TABLE` SQL | | `view_definition` | `CREATE VIEW` SQL, `null` for tables | | `is_view` | boolean | | `private` | `true` if visible to this actor but not anonymously | | `expandable_columns` | `[[foreign_key, label_column_or_null], ...]` | | `form_hidden_args` | pairs of `_`-prefixed args for HTML forms | Non-public extras (`actions`, `filters`, `display_rows`) are HTML-only and never appear in JSON. `_extra=_html` expands to the full HTML bundle (views/table_extras.py:1162-1194). Any `_facet*` argument implicitly adds `facet_results`; `_shape=object` implicitly adds `primary_keys` (views/table.py:2252-2256). There is **no** `filtered_table_rows_count` extra — it was replaced by `count`. **Column filters `?__=`** (filters.py:260-427). Any querystring key not starting with `_` is a filter; bare `?column=value` means `exact`. Columns whose names start with `_` can be filtered as `?_col__exact=`. Operators: | op | SQL | |---|---| | `exact` | `"col" = :p` (default) | | `not` | `"col" != :p` | | `contains` / `notcontains` | `like '%v%'` / `not like '%v%'` | | `endswith` / `startswith` | `like '%v'` / `like 'v%'` | | `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` (numeric strings cast to int) | | `like` / `notlike` | raw `like` / `not like` pattern | | `glob` | `glob` | | `in` / `notin` | comma-separated list, or JSON array if the value starts with `[` | | `arraycontains` / `arraynotcontains` | `[not] in (select value from json_each("col"))` (requires JSON1) | | `date` | `date("col") = :p` | | `isnull` / `notnull` | `is null` / `is not null` (no value) | | `isblank` / `notblank` | `(is null or = '')` / opposite (no value) | **Special (underscore) parameters:** | Param | Behavior | |---|---| | `_where=SQL` | extra raw where clause (repeatable); requires `execute-sql` else 403 `"_where= is not allowed"` | | `_search=q` | FTS against the table's FTS table | | `_search_=q` | FTS restricted to one column; 400 if invalid | | `_searchmode=raw` | pass the query straight to `match` | | `_fts_table=` / `_fts_pk=` | override the FTS table / pk used for joins | | `_through={"table","column","value"}` | filter via an incoming foreign key (repeatable, JSON value) | | `_sort=col` / `_sort_desc=col` | sort; 400 if both given or column not sortable | | `_next=token` | pagination token | | `_size=N\|max` | page size; default `default_page_size` (100); `max` = `max_returned_rows` (1000); 400 on invalid | | `_col=name` (repeatable) | return only pks + these columns; 400 on invalid | | `_nocol=name` (repeatable) | exclude columns; 400 if invalid or a pk | | `_labels=on` | expand every FK column into `{"value", "label"}` | | `_label=col` (repeatable) | expand only the named FK column(s) | | `_facet=col` | request a facet; 400 `"_facet= is not allowed"` when `allow_facet` off | | `_facet_array=col` / `_facet_date=col` | typed facets | | `_facet_size=N\|max` | facet bucket count, default 30, capped at `max_returned_rows` | | `_nocount=1` | skip count (`count` extra → null) | | `_nofacet=1` | skip facets and suggestions | | `_nosuggest=1` | skip facet suggestions only | | `_shape=` | see renderer section; `array`/`object` also force `_nocount` and `_nofacet` | | `_nl=on` | NDJSON with `_shape=array` | | `_json=col` / `_json_infinity=1` | renderer options | | `_timelimit=ms` | custom SQL time limit | | `_ttl=seconds` | `Cache-Control: max-age=N` (`0` → `no-cache`); default `default_cache_ttl` (5) | | `_trace=1` | append `_trace` key (requires `trace_debug` setting) | | `_extra=` | see above | **Pagination** is keyset-based for tables: `page_size + 1` rows are fetched; `next` is built from the last row of the page — comma-joined tilde-encoded primary-key values, prefixed by the sort value when sorted (`$null` for null sort values) (views/table.py:2041-2111, 2421-2482). `next_url` is the absolute URL with `_next` replaced. ### GET /\/\.json (SQL views) Same code path with `is_view=True`. Differences: - No primary keys: `primary_keys` → `[]`; `_shape=object` fails; base query has no `order by`. - **Pagination is offset-based**: `_next` is an integer offset applied as `limit N offset M` (views/table.py:2047-2049, 2438-2439) — unlike the keyset tokens used for tables. - `view_definition` returns the `CREATE VIEW` SQL; `table_definition` is null. ### GET /\/\/\.json `RowView` (app.py:2715-2718; views/row.py:137). `` is comma-separated tilde-encoded primary key values (rowid for rowid tables). - **Permission:** `view-table` (denied → `Forbidden` → 403 HTML). Missing row → 404 `"Record not found: [...]"`. - **Default JSON keys:** `ok`, `database`, `table`, `rows` (single-element list), `primary_keys`, `primary_key_values`, `query_ms`, `truncated: false`; `columns` only with `?_extra=columns`. - **`?_extra=` (ROW scope):** `columns`, `primary_keys`, `render_cell`, `debug`, `request`, `query`, `column_types`, `metadata`, `extras`, `database`, `table`, `database_color`, `private`, `foreign_key_tables` (incoming FKs with `count` and `link`; single-pk rows only). - **Foreign-key label expansion does not apply to row JSON** — `_labels` has no effect here; expansion happens only in the HTML path (views/row.py:445-475). - `_shape`, `_json`, `_nl`, `_json_infinity`, `_ttl` apply. ### The .blob format `//
/.blob?_blob_column=col` (also on query pages) — fetches raw binary bytes (blob_renderer.py:10-61). `_blob_column` required (400 if missing/invalid); optional `_blob_hash` must equal the value's SHA-256 (else 400 `"Link has expired..."`). Returns `application/binary` as a download attachment. In JSON output, binary cells appear as `{"$base64": true, "encoded": "..."}`. ### GET /\/\/-/schema(.json|.md) `TableSchemaView` (app.py:2751-2754; views/special.py:1332-1378). - **Permission:** `view-table` via `ensure_permission` (denied → 403 HTML). - **Responses:** `.json` → 200 `{"ok": true, "database", "table", "schema"}`; `.md` → `text/markdown`; no extension → HTML. Missing table → 404 `{"ok": false, "error": "Table not found"}` for `.json`. ### GET /\/\/-/fragment `TableFragmentView` (app.py:2739-2742; views/table.py:1385-1418). **HTML-only** — returns the `_table.html` partial; no JSON variant. Accepts table querystring parameters plus `_row=` to render a single row. ### GET /\/\/-/autocomplete `TableAutocompleteView` (app.py:2743-2746; views/table.py:1492-1595). Tables only — views get 400 `"Autocomplete is only available for tables"`. - **Permission:** `view-table` (denied → `Forbidden` → 403). - **Parameters:** `q` (matched with escaped `LIKE %q%` against pk columns and the label column) and `_initial` (truthy: with empty `q`, return the 10 most recent rows). Neither → `{"ok": true, "rows": []}`. - **Response:** `{"rows": [{"pks": {pk_name: value}, "label": "..."}]}` — max 10 items; 500 ms query budget with fallbacks, timing out to `{"ok": true, "rows": []}`. --- ## The write API All write endpoints return errors via `_error()` (the canonical error shape) and check permissions with `datasette.allowed()` directly, so their 403s are JSON (unlike the `Forbidden`-raising read endpoints). Routes: app.py:2719-2762. ### POST /\/\/-/insert `TableInsertView` (views/table.py:907-1194). - **Permissions:** `insert-row` on the table (denied → 403 `["Permission denied"]`); `update-row` additionally required for `replace: true` (403 `need update-row to use "replace"`); `alter-table` additionally required for `alter: true` (403 `Permission denied for alter-table`). Immutable database → 403 `Database is immutable`. - **Request** — requires `Content-Type: application/json` (else 400 `"Invalid content-type, must be application/json"`). Body: | Field | Rules | |---|---| | `row` | single object; mutually exclusive with `rows`; forces `return: true` | | `rows` | list of objects; max `max_insert_rows` (default 100), else 400 `"Too many rows, maximum allowed is 100"` | | `ignore` | skip rows whose pk already exists; mutually exclusive with `replace` | | `replace` | replace rows with matching pks (needs `update-row`) | | `alter` | add missing columns (needs `alter-table`) | | `return` | include inserted rows in the response | One of `row`/`rows` required. Unknown keys → 400 `"Invalid parameter: ..."`. Unless `alter`, row keys must be existing columns → per-row 400 `"Row 0 has invalid columns: x, y"`. Values are validated against assigned column types. - **Response** — **201** `{"ok": true}`; with `return: true` also `rows` (the rows as stored, re-fetched by rowid). SQLite errors during the write → 400 with the message. Emits `insert-rows` (and possibly `alter-table`) events. ### POST /\/\/-/upsert `TableUpsertView` — subclasses insert (views/table.py:1197-1201). - **Permissions:** **both** `insert-row` and `update-row` (403 `need both insert-row and update-row`); `alter: true` needs `alter-table`. - **Request:** same as insert, except `ignore`/`replace` are rejected (400 `"Upsert does not support ignore or replace"`) and **every row must contain the table's primary key(s)** (per-row 400 `Row 0 is missing primary key column(s): "id"` / `has null primary key`). - **Response** — **200** (note: insert returns 201) `{"ok": true}`; with `return: true`, `rows` re-fetched by pk. Emits `upsert-rows`. ### POST /\/\/-/alter `TableAlterView` (views/table_create_alter.py:1130-1353). - **Permission:** `alter-table` (403 `need alter-table`); immutable → 403. - **Request:** `{"operations": [{"op": ..., "args": {...}}, ...]}` — a non-empty list, validated by pydantic (extra keys forbidden anywhere; errors → 400 `location: message`): | `op` | `args` | |---|---| | `add_column` | `name` (required), `type` (`text`/`integer`/`float`/`blob`, default `text`), `not_null`, `default` xor `default_expr`; `not_null: true` requires a default | | `rename_column` | `name`, `to` | | `rename_table` | `to` (must not start `sqlite_`) | | `alter_column` | `name` + at least one of `type`, `not_null`, `default`, `default_expr` | | `drop_column` | `name` | | `set_primary_key` | `columns` (non-empty list) | | `reorder_columns` | `columns` (non-empty list) | | `add_foreign_key` | `column`, `fk_table`, optional `fk_column` | | `drop_foreign_key` | `column` | | `set_foreign_keys` | `foreign_keys`: list of `{column, fk_table, fk_column?}` | `default_expr` must be one of the five `current_*` keywords. Operations are applied in a single write transaction; any failure → 400. - **Response** — 200: ```json {"ok": true, "database": "...", "table": "", "table_url": "...", "table_api_url": "...", "altered": true, "schema": "...", "before_schema": "...", "operations_applied": 2} ``` ### POST /\/\/-/drop `TableDropView` (views/table.py:1320-1382). - **Permission:** `drop-table` (403 `Permission denied`); immutable → 403. - **Confirmation flow:** without `{"confirm": true}` in the body, nothing is dropped and a 200 preview is returned: `{"ok": true, "database", "table", "row_count", "message": "Pass \"confirm\": true to confirm"}`. With `confirm: true` → 200 `{"ok": true}`. Emits `drop-table`. ### POST /\/\/-/set-column-type `TableSetColumnTypeView` (views/table.py:1204-1317). Assigns a Datasette *column type* (metadata stored in the internal `column_types` table) — it does not change the SQLite schema. - **Permission:** `set-column-type` (403 `Permission denied`). - **Request** (JSON content type required): `{"column": "name", "column_type": {"type": "url", "config": {...}?} | null}`. Unknown keys/invalid structure → detailed 400 errors; unknown type → 400 `"Unknown column type: x"`. Default registered types (via the `register_column_types` hook): `url`, `email`, `json`, `textarea`. - **Response** — 200 `{"ok": true, "database", "table", "column", "column_type": {...} | null}`. ### GET /\/\/-/foreign-key-suggestions `TableForeignKeySuggestionsView` (views/table_create_alter.py:1008-1127). **GET only** (read-only despite living beside the write endpoints). - **Permission:** `alter-table` (403 `need alter-table`); views → 400 `"Cannot suggest foreign keys for a view"`. - **Response** — 200: `{"ok": true, "database", "table", "row_check": {attempted, status, row_limit, sampled_rows, checked_options}, "columns": [{column, type, affinity, current, "suggestions": [{fk_table, fk_column, confidence, sampled_values, reasons}], "options": [...]}]}`. Samples up to 500 rows within 50 ms/200 ms budgets. ### POST /\/\/\/-/update `RowUpdateView` (views/row.py:781-870). - **Permissions:** `update-row` (403 `Permission denied`); `alter: true` additionally requires `alter-table` (403 `Permission denied for alter-table`). - **404s:** `Database not found: x` / `Table not found: x` / `Record not found: [pks]`. - **Request:** `{"update": {column: value, ...}, "return"?: true, "alter"?: true}`. Missing/non-dict `update` → 400 `"JSON must contain an update dictionary"`; unknown keys → 400 `"Invalid keys: ..."`; write failures (bad column, constraint violation) → 400 with the message. - **Response** — 200 `{"ok": true}`; with `return: true`, `{"ok": true, "rows": [{...}]}` — a single-item list, matching insert/upsert. Emits `update-row`. ### POST /\/\/\/-/delete `RowDeleteView` (views/row.py:738-778). - **Permission:** `delete-row` (403 `Permission denied`). 404s as update. - **Request:** no body required (any body is ignored — there is no confirmation step, unlike table drop). - **Response** — 200 `{"ok": true}`; with `?_redirect_to_table` a `redirect` key is added. A failure during the write returns 400 with the message, matching update. Emits `delete-row`. --- ## Stored (canned) queries API Stored queries live in the internal database's `queries` table (utils/internal_db.py:116-133). Queries defined in `datasette.yaml` are synced in at startup with `source="config"` and `is_trusted` defaulting to true; queries created via the API get `source="user"`, `is_trusted=false`, `owner_id` = actor id. **Canonical stored-query JSON object** (`stored_query_to_dict`, stored_queries.py:55-80): ```json { "database": "...", "name": "...", "sql": "...", "title": null, "description": null, "description_html": null, "hide_sql": false, "fragment": null, "parameters": ["p"], "is_write": false, "is_private": true, "is_trusted": false, "source": "user", "owner_id": "...", "on_success_message": null, "on_success_message_sql": null, "on_success_redirect": null, "on_error_message": null, "on_error_redirect": null, "private": true } ``` `private` appears only in list responses. On input (create/update and `datasette.yaml`), `params` is accepted as an alias for `parameters`. **Default permission rules for queries** (default_permissions/defaults.py): `view-query` is default-allow, but private queries are visible only to their owner; the owner may `update-query`/`delete-query` their `source='user'` queries. ### GET /-/queries(.json) and GET /\/-/queries(.json) `GlobalQueryListView` / `QueryListView` (app.py:2606-2609, 2663-2666; views/stored_queries.py:69-238). The global variant lists queries across all databases (`database`/`database_color` are null, `show_database` true). - **Permissions:** no single gate; results filtered per query by `view-query` (private queries appear only for their owner). - **Parameters:** `_size` (default 20 HTML / **50 JSON**, clamped 1–1000; non-integer → 400), `_next` (cursor), `q` (substring search over name/title/description/sql), `is_write` / `is_private` (booleans; invalid → 400 `"is_write must be 0 or 1"`), `source`, `owner_id`. - **Response** — 200: `{"ok": true, "database", "database_color", "queries": [...], "next", "next_url", "has_more", "limit", "show_private_note", "show_trusted_note", "query_list_path", "show_database", "facets": [{title, items: [{label, count, href, active}]}], "filters": {q, is_write, is_private, source, owner_id}}`. ### GET /\/-/queries/analyze `QueryCreateAnalyzeView` (app.py:2667-2670; views/stored_queries.py:290-322). **GET only** despite being an "analyze" action — POST → 405. - **Permissions:** `execute-sql` then `store-query` (each denial → 403 `errors` JSON). - **Parameters:** only `sql` (others → 400 `"Invalid keys: ..."`). - **Response** — 200: `{"ok", "parameters", "analysis_error", "analysis_rows": [{operation, database, table, required_permission, source, allowed}], "has_sql", "analysis_is_write", "save_disabled"}`. ### POST /\/-/queries/store `QueryStoreView` (app.py:2671-2674; views/stored_queries.py:325-388). GET on the same path renders the HTML create form. - **Permissions:** `execute-sql` + `store-query` (403 `errors` JSON). - **Request:** JSON bodies must wrap the fields: `{"query": {...fields...}}`; form bodies pass fields flat. Fields: `name` (required; `^[^/\.\n]+$`; conflicts with tables/views or existing queries → 400), `sql` (required; read SQL must pass `validate_sql_select`; write SQL must pass per-operation permission checks), `title`, `description`, `hide_sql`, `fragment`, `parameters`/`params` (must exactly match the SQL's named parameters; magic parameters rejected), `is_private` (**default true**), and — only for write SQL — `on_success_message`, `on_success_redirect`, `on_error_message`, `on_error_redirect`. `is_write` is derived from SQL analysis; `is_trusted`, `description_html` and `on_success_message_sql` cannot be set through this API. - **Response:** JSON request → **201** `{"ok": true, "query": {...}}`; form request → 302 redirect. ### GET /\/\/-/definition `QueryDefinitionView` (app.py:2695-2698; views/stored_queries.py:391-408). - **Permission:** `view-query` (403 `["Permission denied"]`). - **Response:** 200 `{"ok": true, "query": {...}}`; 404 `["Query not found: x"]`. ### GET/POST /\/\/-/edit `QueryEditView` (app.py:2699-2702) — **HTML form endpoint** (`has_json_alternate = False`), not part of the JSON API. Programmatic updates use `/-/update`. ### POST /\/\/-/update `QueryUpdateView` (app.py:2703-2706; views/stored_queries.py:411-465). - **Permissions:** `update-query` (403 `need update-query`); trusted queries → 403 `"Trusted queries cannot be updated using the API"`; changing `sql` additionally requires `execute-sql`. - **Request:** `{"update": {...partial fields...}, "return"?: true}` — other top-level keys → 400. Updatable fields: `sql`, `title`, `description`, `hide_sql`, `fragment`, `parameters`/`params`, `is_private`, `on_*` fields (write SQL only). New SQL is re-analyzed and `is_write` recomputed. - **Response:** 200 `{"ok": true}` (plus `query` with `return: true`); 404 `"Query not found: x"`. ### POST /\/\/-/delete `QueryDeleteView` (app.py:2707-2710; views/stored_queries.py:594-644). GET renders an HTML confirmation page. - **Permission:** `delete-query` (403 `need delete-query`). Trusted queries → 403 `"Trusted queries cannot be deleted using the API"`, matching update. - **Response:** JSON request → 200 `{"ok": true}`; form → 302; 404 `"Query not found: x"`. No `confirm` field required (unlike table drop). ### GET/POST /\/\(.json) — executing a stored query No dedicated route: the table route resolves the name, and on `TableNotFound` the request is dispatched to `QueryView` when a stored query matches (views/table.py:1698-1712). Covers both config-defined and API-stored queries. **GET (read queries)** — `QueryView.get` (views/database.py:695-1130): - **Permissions:** `view-query` (denied → `Forbidden` → 403 HTML). Read queries then require `execute-sql` unless `is_trusted`. Write queries are **not executed** on GET — JSON returns empty `rows`; HTML shows a POST form. - **Parameters:** each named `:param` is read from the query string (missing → `""`); `_timelimit`; renderer options (`_shape`, `_nl`, `_json`, `_json_infinity`); `_extra` (QUERY scope). - **Response:** `{"ok": true, "rows": [...], "truncated": false}` + extras. SQL errors → 400 with `error` in the envelope. **POST (write queries)** — `QueryView.post` (views/database.py:574-693): - **Permissions:** `view-query`; then, unless `is_trusted`: `execute-write-sql` on the database **plus** per-operation write permissions (same table as `/-/execute-write`). Rejection → 403 `{"ok": false, "message": "...", "redirect": null}` for JSON clients. Immutable database → 403. - **Body:** form-encoded or JSON `param=value` pairs (values coerced to strings). - **JSON is returned when** `Accept: application/json`, `?_json=1`, or a `_json` body field is present; otherwise 302 + flash message. - **Magic parameters** (`:__`, resolved server-side; registered via `register_magic_parameters`, default_magic_parameters.py): `_now_epoch`, `_now_date_utc`, `_now_datetime_utc`, `_actor_`, `_random_chars_`, `_cookie_`, `_header_` (underscores → hyphens). User-stored queries cannot contain magic parameters — they are a feature of config/trusted queries. - **Response:** success → 200 `{"ok": true, "message": "...", "redirect": "..."|null}` — `message` honors `on_success_message_sql` / `on_success_message`, falling back to `"Query executed"` or `"Query executed, N rows affected"`. SQL failure → **400** canonical error (message honors `on_error_message`) plus a `redirect` context key from `on_error_redirect`. Operation rejection (`QueryWriteRejected`, e.g. VACUUM) → 403 canonical error plus `redirect: null`. --- ## Authentication and tokens ### Bearer tokens (`dstok_`) Signed API tokens are sent as `Authorization: Bearer dstok_...`. The `actor_from_signed_api_token` hook (default_permissions/tokens.py:25-40) passes the token to `datasette.verify_token()`, which tries every handler registered via `register_token_handler`; the default is `SignedTokenHandler` (tokens.py:117-193). - **Format:** `dstok_` + itsdangerous-signed payload (namespace `token`) containing `a` (actor id), `t` (creation Unix time), optional `d` (duration seconds), optional `_r` (restrictions). - **Verification:** a `dstok_`-prefixed token that fails verification — `allow_signed_tokens` off, invalid signature, missing/non-integer `t`, malformed `d`, or expired — raises `TokenInvalid`, and the request fails with **401**, the canonical error body and a `WWW-Authenticate: Bearer error="invalid_token"` header (even if a valid `ds_actor` cookie is also present). Tokens with prefixes no registered handler recognizes are ignored (they may belong to an auth plugin). The effective duration is `d` capped by `max_signed_tokens_ttl` (default 0 = no cap; a non-zero setting also imposes a TTL on tokens without `d`). - **Resulting actor:** `{"id": , "token": "dstok"}` plus `"_r"` and `"token_expires"` when applicable. **Restrictions (`_r`)** (default_permissions/restrictions.py): - `"a"`: list of actions allowed on any resource - `"d"`: `{database_name: [actions]}` - `"r"`: `{database_name: {table_name: [actions]}}` Actions are stored as abbreviations when available (see appendix); checks accept either the full name or the abbreviation. Restrictions are an allowlist filter layered on top of normal permission resolution — a restricted token can never do more than its allowlist, and never more than the underlying actor could do anyway. ### Token creation - **`/-/create-token`** is an HTML form endpoint only (see the instance section) — there is no JSON API to mint tokens in this codebase. - Programmatic alternatives: the `datasette create-token` CLI command and the `datasette.create_token()` Python API. - `/-/auth-token` is the one-time `--root` login mechanism, unrelated to API tokens. ### Cookie authentication Browser sessions use the signed `ds_actor` cookie (set by `/-/auth-token`, plugins, or login flows; cleared by `/-/logout`). API POSTs from browsers are subject to the cross-origin checks described in [CSRF](#csrf--cross-origin-protection). --- ## Appendix: registered actions (permissions) From `datasette/default_actions.py` (registered via the `register_actions` hook). Token restrictions store the abbreviation when available. | Action | Abbr | Resource level | Notes | |---|---|---|---| | `view-instance` | `vi` | global | | | `permissions-debug` | `pd` | global | gates the debug endpoints | | `debug-menu` | `dm` | global | UI only | | `view-database` | `vd` | database | | | `view-database-download` | `vdd` | database | `also_requires="view-database"` | | `execute-sql` | `es` | database | `also_requires="view-database"`; denied when the `default_allow_sql` setting is off | | `execute-write-sql` | `ews` | database | `also_requires="view-database"` | | `create-table` | `ct` | database | | | `store-query` | `sq` | database | `also_requires="execute-sql"` | | `view-table` | `vt` | table | | | `insert-row` | `ir` | table | | | `delete-row` | `dr` | table | | | `update-row` | `ur` | table | | | `alter-table` | `at` | table | | | `set-column-type` | `sct` | table | | | `drop-table` | `dt` | table | | | `view-query` | `vq` | query | default-allow; private queries restricted to their owner | | `update-query` | `uq` | query | query owner allowed by default (source=`user` only) | | `delete-query` | `dq` | query | query owner allowed by default (source=`user` only) |