Configuration reference¶
Every deployment is described by a single cert.config.json. This page is the authoritative list of keys; the JSON Schema at cert.schema.json powers editor autocomplete.
Top-level shape¶
{
"$schema": "β¦/cert.schema.json",
"project": { "name": "DEMO", "slug": "demo", "locale": "en", "branding": { β¦ } },
"rounds": [{ "id": β¦, "label": β¦, "table": β¦, "pdf": β¦ }],
"subjects": [{ "code": β¦, "en": β¦, "vi": β¦, "db_col": β¦ }],
"results": { "<subject_code>": { "<result_name>": <page_number> } },
"data_mapping": { "sbd_col": β¦, "name_col": β¦, β¦ },
"layout": { "page_size": [842, 595], "fields": { "<field>": { β¦ } } },
"fonts": { "<font_key>": "<relative/path.ttf>" },
"student_search": { "mode": β¦, "admin_mode": β¦ },
"admin": { "auth_mode": β¦, "multi_user": β¦, "roles": [β¦] },
"features": { "qr_verify": { β¦ }, "shipment": { β¦ }, "otp_email": { β¦ }, "gsheet_log": { β¦ }, "kv_backend": β¦ }
}
project¶
| Key | Type | Notes |
|---|---|---|
name |
string | Displayed in the page header. 1β120 chars. |
slug |
string | Lowercase kebab-case, used in signed QR payloads to bind certs to the issuer. |
locale |
"en" / "vi" |
Default UI language. |
branding.primary_color |
hex | CSS --primary value. |
branding.accent_color |
hex | CSS --accent value. |
branding.logo_url |
string or null | Must start with /, http(s)://, or data:image/. javascript: rejected. |
rounds and subjects¶
Each round is a set of certificates (e.g. qualifier vs finals); each subject is a parallel discipline (e.g. Math / Science). Every round shares the full subject list.
rounds[].id+subjects[].codemust be unique and match^[A-Za-z0-9][A-Za-z0-9_-]*$.rounds[].table+subjects[].db_colmust be SQL identifiers (^[A-Za-z_][A-Za-z0-9_]*$).rounds[].pdfis relative to the project root; absolute paths and..traversal are rejected.
results¶
Maps subject_code β { result_name: page_number }. Example:
"results": {
"S": { "GOLD": 1, "SILVER": 2, "BRONZE": 3 },
"E": { "GOLD": 4, "SILVER": 5, "BRONZE": 6 }
}
Rules: every subjects[].code must appear as a top-level key; page numbers are β₯ 1 and unique within each subject. Result names in the source Excel are matched accent- and case-tolerantly.
data_mapping¶
Maps logical roles to source column names (i.e. what the Excel/CSV header calls them). All values must pass the SQL-identifier regex.
| Key | Required |
|---|---|
sbd_col |
yes |
name_col |
yes |
dob_col, school_col, grade_col, phone_col |
optional β enable their respective search modes |
extra_cols |
string[] β flex fields ingested into the schema |
layout¶
{
"page_size": [842, 595],
"fields": {
"name": { "x": 421, "y": 330, "font": "script", "size": 40, "color": "#1E3A8A", "align": "center", "wrap": null },
"school": { "x": 421, "y": 280, "font": "serif", "size": 18, "align": "center", "wrap": 60 }
}
}
Field keys are the logical names the engine fills (e.g. name, school, grade, dob, phone). font must point to a key in the top-level fonts registry; align is left / center / right; wrap (optional) line-wraps at that many characters.
fonts¶
{ "<key>": "<relative_ttf_path>" }. Paths cannot be absolute, contain .., or start with a drive letter. Keys are the tokens referenced by layout.fields[*].font.
student_search¶
mode:name_dob_captcha(default) /name_sbd_captcha/sbd_phone.admin_mode:sbd_auth(default) /sbd_phoneβ used by the admin panel's search form.
admin¶
| Key | Notes |
|---|---|
auth_mode |
password, otp_email, or magic_link. Controls the login flow shape. |
multi_user |
Currently informational; the auth table always supports multiple users. |
roles |
Non-empty list; the built-ins are super-admin, admin, viewer. |
features¶
qr_verify¶
| Key | Notes |
|---|---|
enabled |
bool |
private_key_path |
defaults to private_key.pem. Relative, no traversal. |
public_key_path |
defaults to public_key.pem. |
x, y, size_pt |
Where the engine draws the QR on each overlaid page (PDF points). |
max_age_seconds |
0 (default) disables expiry; non-zero rejects verify requests older than N seconds. |
shipment¶
| Key | Notes |
|---|---|
enabled |
bool |
statuses |
Non-empty, case-insensitive-unique status vocabulary. |
fields |
Extra TEXT columns on the shipments table. Each must be a SQL identifier; clashes with reserved names (id, round_id, sbd, status, created_at, updated_at) are rejected. |
public_fields |
Subset of fields that the public lookup endpoint is allowed to return. Default empty β students see only status + updated_at. |
kv_backend¶
local (default), upstash, or vercel-kv. See Deploy β Vercel for the env vars each backend needs.
Other feature flags¶
otp_email.enabled+otp_email.provider: "resend"β wires OTP login. NeedsRESEND_API_KEYandCERT_EMAIL_FROMenv vars.gsheet_log.enabledβ forwards admin activity toGSHEET_WEBHOOK_URLon a background thread.
Environment variables¶
Config file handles what the portal does; env vars handle where it runs. Full list with defaults:
Required¶
| Name | Notes |
|---|---|
JWT_SECRET |
32+ random chars. No ephemeral fallback β missing value raises TokenError at startup. Rotate on compromise (nukes all sessions). |
PUBLIC_BASE_URL |
Pins magic-link emails + QR verify URLs against Host-header injection. Set to the exact HTTPS origin. |
Commonly set¶
| Name | Default | Notes |
|---|---|---|
ALLOWED_ORIGINS |
* |
Comma-separated CORS whitelist. Leave as * only if the portal is fully public. |
TRUST_PROXY_HEADERS |
0 |
Set to 1 when deploying behind Nginx / Caddy / Vercel / Cloud Run so the app reads X-Forwarded-For for rate-limit bucketing. Do NOT enable on direct binds β clients could spoof the header to bypass the limiter. |
FORCE_HSTS |
0 |
Set to 1 once the site is reachable only via HTTPS. Emits Strict-Transport-Security: max-age=31536000; includeSubDomains on every response. Browsers cache HSTS β enabling on HTTP locks users out. |
WEB_CONCURRENCY |
2 |
Gunicorn worker count. Dockerfile sets this; if paired with KV_BACKEND=local and >1 worker, a startup warning fires (local KV is not cross-process safe). |
KV_BACKEND |
local |
local / upstash / vercel-kv. See operations. |
Auth / email¶
| Name | Notes |
|---|---|
ADMIN_DEFAULT_PASSWORD |
Used by the one-off admin-bootstrap script. Rotate immediately after first login. |
RESEND_API_KEY |
Required for otp_email / magic_link auth modes. Without it, _resolve_email_provider falls back to NullEmailProvider and logs a warning β OTP / magic-link flows will silently drop messages. |
RESEND_FROM_ADDRESS / CERT_EMAIL_FROM |
Verified Resend sender. Either name works (alias for backwards compat with .env.example). |
Storage backends¶
| Name | When set |
|---|---|
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN |
KV_BACKEND=upstash |
KV_REST_API_URL / KV_REST_API_TOKEN |
KV_BACKEND=vercel-kv (auto-injected by Vercel) |
KV_LOCAL_PATH |
Override default ./.kv/store.json location |
Optional integrations¶
| Name | Notes |
|---|---|
GSHEET_WEBHOOK_URL |
Must be https://β¦. Non-HTTPS URLs are rejected with a warning (SSRF guard). Fire-and-forget β local audit table is authoritative. |
GUNICORN_WORKERS / UVICORN_WORKERS |
Detected by the KV factory alongside WEB_CONCURRENCY for the multi-worker warning. |
Validation errors¶
If a value is rejected at config load time you'll see a message like:
cert.config.json failed validation (β¦/cert.config.json):
- rounds.0.pdf: round.pdf must be a relative path (got absolute '/etc/passwd')
The file path is always included; raw input values never are (keeps secrets out of the error stream).