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 NameWhy It's Blocked
hashed_passwordUser password hash — never exposed, even hashed
previous_hashed_passwordPrevious password hash, stored during password rotation
invitation_codeUnique invitation token — exposing it would let anyone join
search_textFulltext search index column — noisy, large, internal-only
password_hashAlternative 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

LayerControlsScope
API_BLOCKLISTColumns stripped from JSON responsesRead API only
IGNORE_INDEX_FIELDS (db_structure)Columns excluded from HTML list viewsAdmin UI only
Module guards (require_module_mw)Which users can access a route at allAll routes
Global scopesWhich rows a user can see within a tableAll queries
api: true in schema/table.tsWhether a table has a Read API endpoint at allPer-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.