Read API & Column Blocklist
config/api_blocklist.ts defines the list of columns that are never serialized over the Read API, regardless of which table they're in. This prevents accidentally publishing sensitive data when a table has api = true and its sql.ts does SELECT *.
The Read API is an optional per-table opt-in (api: true in schema/table.ts) that exposes a JSON list endpoint at /<table>?format=json. The blocklist is an additional safety net — even if someone forgets to exclude a column from the SELECT, the serialization step strips it.
Configuration File
export const API_BLOCKLIST: string[] = [
"hashed_password",
"previous_hashed_password",
"invitation_code",
"search_text",
"password_hash",
];
export function strip_api_sensitive(
rec: Record<string, unknown>
): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rec)) {
if (!API_BLOCKLIST.includes(k)) clean[k] = v;
}
return clean;
}
API_BLOCKLIST
export const API_BLOCKLIST: string[] = [
"hashed_password",
"previous_hashed_password",
"invitation_code",
"search_text",
"password_hash",
];
A flat string array of column names. Any column whose name appears in this list is stripped from every record before it's sent over the Read API, regardless of which table the record came from.
| Column Name | Why It's Blocked |
|---|---|
hashed_password | User password hash — never exposed, even hashed |
previous_hashed_password | Previous password hash, stored during password rotation |
invitation_code | Unique invitation token — exposing it would let anyone join |
search_text | Fulltext search index column — noisy, large, internal-only |
password_hash | Alternative name for hashed passwords in some schemas |
Column names are matched exactly — the check is API_BLOCKLIST.includes(k), not a substring or suffix match. A column called admin_password_hash would NOT be blocked unless its exact name is in the list.
strip_api_sensitive()
function strip_api_sensitive(
rec: Record<string, unknown>
): Record<string, unknown>;
Iterates over every key in a record object and returns a new object containing only the keys not in API_BLOCKLIST. Called by the generated <table>/index.ts route handler before JSON-serializing each row.
Input:
{
id: 1,
name: "Aleš",
email: "ales@example.com",
hashed_password: "$argon2id$v=19$m=65536...",
search_text: "aleš ales@example.com",
created_at: "2026-01-15T10:30:00Z"
}
Output:
{
id: 1,
name: "Aleš",
email: "ales@example.com",
created_at: "2026-01-15T10:30:00Z"
}
hashed_password and search_text are stripped; all other columns pass through unchanged.
Extending the Blocklist
To block additional columns, add them to the API_BLOCKLIST array:
export const API_BLOCKLIST: string[] = [
"hashed_password",
"previous_hashed_password",
"invitation_code",
"search_text",
"password_hash",
"internal_notes", // added
"salary", // added
];
The blocklist is global — it applies to every table with the Read API enabled. For per-table or per-role access control, use Authorization (module guards and global scopes) instead.
How It's Consumed
The blocklist is read by the CRUD generator (generator/crud/) when scaffolding Read API endpoints. Generated route handlers call strip_api_sensitive() on every record before JSON serialization:
// Generated in <table>/index.ts
import { strip_api_sensitive } from "$config/api_blocklist";
// Inside the list handler:
const clean_records = records.map(strip_api_sensitive);
return Response.json(clean_records);
The blocklist is not applied to the admin UI (the HTML-rendered list views rendered by render()). It only applies to the JSON Read API endpoint (?format=json). Admin pages rendered as HTML go through the template engine and are controlled by IGNORE_INDEX_FIELDS in config/db_structure.ts instead.
Relationship to Other Security Measures
| Layer | Controls | Scope |
|---|---|---|
API_BLOCKLIST | Columns stripped from JSON responses | Read API only |
IGNORE_INDEX_FIELDS (db_structure) | Columns excluded from HTML list views | Admin UI only |
Module guards (require_module_mw) | Which users can access a route at all | All routes |
| Global scopes | Which rows a user can see within a table | All queries |
api: true in schema/table.ts | Whether a table has a Read API endpoint at all | Per-table opt-in |
The Read API endpoint only exists when api: true is set in the table's schema. The blocklist is the last line of defence — even when everything else is configured correctly, it guarantees that password hashes and invitation codes never leave the server through the JSON API.