BACKEND OMIB - Deployment and verification
27 October 2026
========================================

API contract (same JSON responses for Node/Cloudflare and PHP)
------------------------------------------------------------
GET /api/assessment/status
  { emailReady: boolean, preview: boolean,
    formId: "omib-set1-pilot-v1", items: 22 }

POST /api/assessment/session
  Request: { lang: "es" | "en", adult: true }
  Response: { token, formId, lang, timed: false, total: 22,
    items: [{ id, asset }], practice: [{ id, asset }],
    choices: [{ id, asset, label }] }
  Sets the HttpOnly, SameSite=Strict session cookie. Secure outside localhost.

POST /api/assessment/practice
  Request: { token, id: "omib-019" | "omib-020", selected: [1..20] }
  Response: { correct: boolean, correctElements: number[], explanation: string }
  Feedback is restricted to the two unscored practice items.

POST /api/assessment/results
  Request: { token, email, answers: [{ id, selected: number[] }],
    privacy: true, newsletter: false, hp: "" }
  Exactly 22 distinct scored item IDs must be supplied; [] means omitted.
  Response: { score, answered, total: 22, duration: seconds,
    delivery: "sent" | "preview", lang, formId }
  No IQ, percentage-to-IQ conversion or population percentile.
  Errors: { error: "machine_code", message: "human readable message" }.

The browser must POST application/json from the same origin and retain its
session cookie. Typical fetch() defaults already do this. Cross-origin requests
are refused. Bodies above 16 KiB, invalid IDs, duplicate components, tokens with
invalid signatures, replayed reports, honeypot values and missing privacy
confirmation are refused. Newsletter collection is not implemented; true is
rejected rather than silently collecting/discarding marketing consent.

Preview behavior
----------------
PREVIEW_MODE=true on Node or preview=true on PHP means no real report email is
sent and no email is stored. The supplied email is ignored. The result is
returned on screen with delivery="preview". The interface should disclose this
and avoid asking for an email in preview. A remote preview still needs a random
ASSESSMENT_SECRET of at least 32 characters. A development secret fallback only
exists for localhost/127.0.0.1/::1.

When preview=false, missing legal readiness or delivery configuration causes
503 before parsing email-bearing result bodies. This is deliberate: never
pretend that an email was sent. delivery="sent" means the configured provider or
mail server accepted the message; it does not guarantee inbox delivery.

Cloudflare / Sites / Vinext
---------------------------
Files:
  web/lib/assessment-service.ts
  web/lib/omib-bank.json           PRIVATE server import, never client import
  web/app/api/assessment/[action]/route.ts
  web/db/schema.ts                assessment_guard table
  web/lib/backend.test.mjs

Manifest: d1="DB". Generate and deploy the Drizzle migration for
assessment_guard (key TEXT PK, expires INTEGER, count INTEGER, expiry index).
The service fails closed if the production DB/table is unavailable. It does not
store emails, answers or assessment results, only HMAC hashes/rate counters and
random nonce guards. The full signed session is stateless and expires in 4h.

Production environment:
  ASSESSMENT_SECRET = random secret, >=32 characters
  PREVIEW_MODE = false
  LEGAL_READY = true              only after owner/contact/privacy are complete
  RESEND_API_KEY = configured sender API key
  FROM_EMAIL = verified sender identity
  MAX_DAILY_REPORTS = 100         optional; default100, capped10000

Current remote private preview: PREVIEW_MODE=true and random secret, without a
mail provider. Do not create a new mail account or use somebody else's sender
identity merely to make the preview look live.

Rates: 30 sessions/hour/IP-hash, 120 practice checks/hour/IP-hash,
6 result submissions/hour/IP-hash, 2 sending attempts/day/email-HMAC, and
MAX_DAILY_REPORTS/day globally. A replayed token never sends a second report.
The Resend sender also uses a nonce-based idempotency key. Failed sends release
the replay lock for retry, while counting against abuse limits.

Guard records expire logically within 1-24h and are purged on later requests.
Do not describe physical deletion as exactly24h without adding a scheduled
database cleanup operation. No personal result records are retained.

Test command with the bundled Node24 runtime:
  node --experimental-strip-types --test lib/backend.test.mjs
The 21 tests use a real in-memory SQLite engine for the D1 SQL and a stub mail
sender. They send no external emails. Scoped TypeScript verification also passed.

Hostinger PHP / static frontend
------------------------------
Requires PHP8.1+ and HTTPS. Deploy api.php inside public_html. Put these outside
public_html (for example in sibling private/):
  assessment-config.php           copy/adapt hostinger/config.example.php
  omib-bank.json                  copy web/lib/omib-bank.json, includes instructions
  assessment-store/              writable only by hosting account; created0700

The default config path is dirname(api.php directory)/private/assessment-config.php.
ASSESSMENT_CONFIG can override the path. The live service refuses a bank or guard
directory resolved inside DOCUMENT_ROOT. Never deploy config.example.php as a
live config with an empty/default secret.

Keep origin exactly equal to the HTTPS frontend origin. For a temporary domain,
set origin to that domain and preview=true. For production, set preview=false,
legal_ready=true, a random secret, mail_enabled=true and verified from_email.
No production runtime can fall back to a development secret on the real domain.

Options for delivery:
  transport="mail": PHP mail() via the existing hosting setup. It must return
                    true before delivery="sent" is returned.
  transport="smtp": native authenticated SMTP with TLS. Configure hostname,
                    port465/securityssl or port587/securitytls, username/password.
                    Certificate verification is enabled. No credentials are logged.

Both transports fail honestly with502 on rejected/failed send and allow retry.
Rate/replay protection uses one flock()-protected private JSON guard file, so it
works across PHP requests/processes. No email, answer or result is written there.
Avoid placing a CDN page cache in front of the API. Responses are no-store.

The PHP file accepts /api.php?action=session (and status/practice/results), or
can be routed to the same API paths as Node using Apache rules such as:
  RewriteEngine On
  RewriteRule ^api/assessment/(status|session|practice|results)/?$ api.php?action=$1 [QSA,L]

A portable official PHP8.4.27 CLI runtime was obtained from windows.php.net and
its SHA256 verified against the official releases.json. Both PHP files pass
php-l. Thirteen checks against a real local PHP built-in server pass, including
scoring, omissions, cookie/Origin binding, malformed answers, replay, rate,
body limit, and fail-closed production without mail. No external emails sent.
QA details are in tmp/php-qa/result.json. A deployed endpoint check and actual
sender acceptance test are still needed before enabling production email.

An actual mail test must target only the user-authorized mailbox. The PHP entry
supports CLI-only loading of its helper functions:
  define('OMIB_LIBRARY_ONLY', true); require '/path/to/api.php';
Then a private CLI script can load the private sender config, set mail_test=true
and call sendMailReport() with dummy score/answered/total/duration and the single
authorized address. The subject/body will clearly mark it TEST and fictional.
Keep public preview enabled during this test. This gate works only in PHP_SAPI
cli, not cli-server/web, and adds no diagnostic HTTP endpoint. Never put that
private CLI script or SMTP credentials inside public_html.

License/source packaging
------------------------
The private answer key belongs on the server, not in public API responses.
Preserve the applicable GPL source, notices and original attribution in a
separate source distribution. Public source availability means the assessment
must not claim that its openly published item bank is cheat-proof.
