Real logic. Offline collection. Analysis-ready data.
A multi-tenant platform for building forms with genuine skipping and validation logic, circulating them to the people who actually have the answers, collecting responses anywhere β including where there is no signal β and exporting data that is ready for analysis rather than merely downloaded.
Accounts are invitation only. Every organisation sees its own data and nothing else, and that boundary is enforced by PostgreSQL, not by the application.
Features Β· Screenshots Β· Architecture Β· Tech stack Β· Quick start Β· Project structure Β· Configuration Β· API Β· Usage Β· Performance Β· Testing Β· Deployment Β· Contributing Β· Roadmap Β· FAQ Β· License Β· Support
Click to expand the full outline
| 1. The product | What it is Β· Who it is for Β· Features Β· Screenshots Β· Demo |
| 2. Engineering | Architecture Β· How offline sync works Β· How tenancy is enforced Β· Tech stack |
| 3. Running it | Quick start Β· Project structure Β· Environment variables |
| 4. Building on it | API Β· Usage examples Β· Performance Β· Testing Β· Deployment |
| 5. Community | Contributing Β· Roadmap Β· FAQ Β· Acknowledgements Β· License Β· Support |
Inicio is three things that are usually three different products:
- An authoring console. Write a form as a schema or as raw JSON, watch a live analyser report
what is wrong with it, import an XLSForm workbook or a Word
.docxquestionnaire, publish an immutable version, and route a question's visibility, validation and calculation through a real expression engine. - A collection app that does not need a connection. The definition is cached on the device, every keystroke is written to IndexedDB before anything is sent, and a durable outbox syncs when the network returns. A retried submission never becomes a second response, and a field edited on two devices is kept twice, never silently overwritten.
- An export pipeline. CSV bundle (data + codebook + value labels + missing-code table), XLSX,
JSON, and SPSS
.sav/ Stata.dtawritten by the pyreadstat worker β which refuses politely, naming the variable that would change it, wherever no worker is deployed, instead of shipping a file that is quietly wrong.
Around those: organisations, roles, invitation-only onboarding, single-use distribution links, consent recorded with evidence, an append-only audit trail, and row-level security that the database itself enforces.
Research teams, NGOs and public agencies running household, school, health or monitoring surveys in places where connectivity is a hope rather than a guarantee; teams who need the data to arrive analysis-ready; and anyone who wants the tenant boundary to be a database guarantee rather than a coding convention.
|
The collection app is an installable PWA. Answers live in IndexedDB, the queue survives a reload, a crash or a dead battery, and the header says plainly whether the server has everything, how many times it has tried and when it will try again. A copy of the draft can be saved to a file and restored on another device, with no connection involved. |
When two devices disagree about a field, the server's value is stored and the device's value is retained beside it, with the disagreement recorded as a first-class row. |
|
Every tenant table has |
Consent records the policy version, the hash of the exact text, the timestamp, IP and user agent. Imports report what they could not carry instead of dropping it. |
|
Relevance, constraints, calculations and choice filters are parsed, validated and evaluated by one canonical implementation that the API and the browser both use β so a question hidden in the console is hidden on the device, and a calculation is never trusted from the client.
|
A service worker caches the app shell and never the data. The definition is cached per link, drafts are written to IndexedDB on every change, and autosave fires on blur, on tab hide, on page close, on reconnect and on a 2-minute timer.
|
|
|
A device-generated UUID is the response id. A start that is retried after a dropped connection
resumes the same response; a retried submit answers
|
Every sync sends
|
|
|
Least-privilege forged- |
No open signup path exists. An owner or admin issues a single-use invitation link; the person signs in
with a one-time code that is hashed with a server-side pepper. Sessions are
|
|
|
XLSForm is an adapter, not the model. Importing compiles to the canonical schema, reports what it
could not carry, and the guarantee
|
CSV bundle (data, codebook, value labels, missing codes), XLSX and JSON, generated from the definition version that actually collected the data.
|
|
|
A published version is frozen. Responses stay attached to the version that collected them, and
restoring an old version forks a new draft rather than rewriting history. The history is a graph:
each version records the one it was forked from, so the console shows who created it, when, which version
it descends from, what changed in words (
|
Analytics, response counts and conflict totals are recomputed in the tenant-scoped transaction on the request that paints them, and the console is told when to make that request: one server-sent event stream per form names the aggregates that moved, coalesced per tick. A quiet form writes no event at all, and twenty responses landing in one second produce one event rather than twenty. There is no cache to go stale and no nightly job to wait for.
|
|
|
Speeders, straight-liners, duplicate submissions and a flag a person raises by hand all land in one queue β each row carrying the evidence the detector recorded (the duration against the threshold, the value that repeated, the response it duplicates) and the decision that closed it, with who made it, when and why. The duplicate scan is an organiser's act rather than a submission-time one, because a respondent's transaction may only see its own response.
|
An API key is a restricted session for the person who minted it, so it inherits the tenant boundary the
database already enforces and dies with their membership. It reaches a route only when that route
declares the capability it needs, which makes the machine surface a list you can read. Webhooks are
enqueued in the same transaction as the event they describe and signed over a timestamp and the body,
so a receiver can prove a delivery is ours and cannot be replayed. A refusal is data too: a key missing a
scope, a viewer attempting a write and a key pointed at another organisation are each answered
|
Open a form and the studio shows the same definition three ways at once: the JSON the API stores,
the XLSForm sheets that
|
|
The panel on the right is rendered by
|
The middle panel does not re-derive an XLSForm from the definition: it calls
|
βββββββββββββββββββββββββββββββ
β the definition (JSON text) β
ββββββββββββββββ¬βββββββββββββββ
JSON.parse β per keystroke
ββββββββββββββββ΄βββββββββββββββ
β one parsed definition β
ββββ¬ββββββββββββ¬βββββββββββββ¬βββ
xlsFormSheets β β validateFormβ evaluateForm(form, answers)
βΌ βΌ βΌ
βββββββββββββββ βββββββββββββ ββββββββββββββββββββ
β XLSForm β β analyser β β the respondent's β
β sheets β β findings β β form (preview) β
βββββββββββββββ βββββββββββββ ββββββββββββββββββββ
β β
the same rows the .xlsx is zipped from the same component the
(`Download .xlsx` writes them verbatim) collection page renders
Nothing in that picture is a server call, a cache or a fixture. The definition in the middle is the
text in the left panel, parsed in the browser by the engine module the API also runs; the only
request the screen makes is the one that loaded the version it is showing. When the JSON is half
typed the panels say what it is β "not valid JSON", "questions is not an array" β and keep
standing, rather than showing the definition they used to have.
Also in the box β the smaller things that make it usable
| πͺ One origin by default | The SPA and the API ship as one Vercel project, so the session cookie is host-only and there is no cross-site login story to debug. Netlify mirrors the SPA and proxies the API to the same origin. |
| π§Ύ Consent with evidence | terms_acceptance stores policy version, text hash, timestamp, IP and user agent, append-only and tenant-scoped. |
| π΅οΈ Audit trail | Material writes (membership, role, invitation, publish, import, export) are recorded as org-scoped audit rows. |
| π₯οΈ Live analyser | The studio validates the definition on every keystroke β not on save β and reports errors and warnings with the question name in them, in the panel you are typing into. |
| π A live monitor for every form | Each published form has a Live monitor tab that charts every answerable field, not a hand-picked few: bars for single and multi select, a histogram with mean, median, minimum and maximum for numbers, a daily line for dates, coordinates for geo fields, and presence counts for files. The screen reads the live analytics aggregate, so a submission lands on the next push; a timer runs only while the stream is down. Every chart has a data table, a base size and an explicit empty state, and every table exports as CSV, one question at a time or as one tidy file. The same model emits a deterministic Power BI report plan with DAX measures, a 1280Γ720 layout on an 8 px grid and the honest limitation that Microsoft does not write a model-bearing .pbix outside Desktop. pnpm fabric:deploy then publishes a Desktop-made PBIX or a PBIR report folder through the supported Microsoft APIs when a service principal is configured. For a licence-free path, pnpm superset:provision uploads the same tidy CSV to the self-hosted Apache Superset stack in docker/superset; Superset is Apache-2.0 with no seat or report cap, and the resulting dashboard is an explicit snapshot rather than the live monitor. See docs/POWER-BI.md and docs/FREE-BI.md. |
| π‘ An honest live indicator | The console header says whether the push channel is connected, and the screens fall back to a timer only when it is not. "Live" is a claim about the connection, not a style. |
| π An integration surface, not a token | An API key is a restricted session for its creator: same tenant boundary, narrower reach, dead the moment they leave the team. Revocation is not a second mechanism that can drift. |
| π Webhooks that survive a bad receiver | Enqueued in the publishing transaction, signed over a timestamp and the body, retried with backoff, and self-disabling with a stated reason after five consecutive failures β plus a Send test button that reports what the receiver actually answered. |
| π§Ύ A quality queue, not a score | Speeding, straight-lining, duplicates and findings raised by hand, each row showing the evidence behind it and recording who decided what, when and why. |
| π A public surface with nothing invented on it | The landing page is one six beat flight through the product β a typographic hero with no 3D object in it, then the questionnaire as a program, offline collection losing and regaining signal, two devices disagreeing, a silent fly-through, three sealed tenants and the invitation β and every object in it depicts something the software actually does. Every figure on the page is read in that request from GET /api/v1/public/summary, and the scene is decoration: it is aria-hidden, its own chunk, it stops when the tab is hidden or the flight has scrolled past, under prefers-reduced-motion it draws one frame per scroll position and never starts a loop, and the page is complete and readable with no WebGL at all. |
| π¨ A brand palette with the contrast measured, not hoped for | One palette, in both stylesheets: an orange primary (#ea580c) on a white ground, one near-black surface (#191c21) for the stage, the footer and the front door, and a peach accent that appears only on that surface, where it is 10:1 rather than the 1.9:1 it would be on white. The primary is two tokens for the same reason β #ea580c as a fill, a darker #c2410c as type β because the brand value is 3.6:1 as a glyph on white and text needs 4.5:1. All 26 pairs are computed from the stylesheets in apps/web/test/contrast.test.ts, which also holds the two stylesheets to each other's values and asserts that the surface re-binds every colour role. |
| πΌοΈ The brand in the things a stylesheet cannot reach | The palette is not only CSS, and the artifacts that are not CSS are the ones that go stale: the README's drawings were still rendered in the previous navy-and-blue palette, the badges on this page were drawn in it, and the app icons β the one asset seen in every browser tab and on every home screen β were generated once before the palette existed and measured 92 % blue-dominant pixels two palette changes later. The drawings and badges are now checked by value and the icons by decoding their pixels, in apps/web/test/brand-assets.test.ts; the icons are generated from the palette's own values by pnpm icons (scripts/make-icons.mjs) rather than edited, because a PNG is not something anybody re-reads. |
| πͺ One product, two materials | Every screen is made of the same two materials, and which one is a fact about the screen rather than about a visitor's operating system: paper is the workspace, the respondent's form and the reading sections of the landing page, and the surface is the first paint, the sign-in sheet, the consent and legal documents, the landing page's stage and the studio's source panel. The dark material is one class (.surface) that re-binds the palette for its subtree, so a card, a field or a heading added inside it arrives in the right material without a rule of its own. Over the console sits an ambient light field of its own, drawn on a 2D canvas: nodes and filaments rather than the landing page's flight, with the same type scale, the same radius language and the same entrance. Every figure on a console screen is live, and the whole shell is complete with no canvas and under prefers-reduced-motion. |
| π« A real template | The 2026 school ranking survey β the project this platform grew out of β ships as template #1, not as the product. |
| πΆ Honest connection state | "No connection. Your answers are saved on this device and will be sent automatically", plus the attempt count and the time of the next try, a Send now button, the timestamp the server has everything up to, and Save a copy / Restore a saved copy for the answers themselves β a file, written and read with no network at all. |
| π³ A self-hosted profile | Compose brings up PostgreSQL 16, Valkey, MinIO, ClamAV and Mailpit, with Caddy in front. |
| π§ͺ Two live harnesses | 35 end-to-end consumer checks and 28 offline-sync checks drive a real database through the same origin the browser uses. Each harness prints its own pass/fail line and reports the count it actually ran. |
| π€ Checked on every push | GitHub Actions runs lint, typecheck, 596 tests against a real PostgreSQL 16, the build, the bundle budgets and both harnesses against a running stack, on a push to main and on every pull request. |
| π°οΈ Checked on a schedule, against production | Every six hours .github/workflows/drift.yml asks the deployment which migrations its own database has applied and compares that with main, opening one issue when they disagree. This is the failure a green CI run cannot see: the code and its migrations agree with each other while the deployed database is behind. |
| βοΈ Budgets, not intentions | The build fails if the critical path grows past 80 kB gzipped, if any chunk passes 150 kB, if the WebGL scene or the XLSForm writer leaks out of its lazy chunk, if a measured colour pair drops under 4.5:1, or if axe-core finds a violation on the public page or the consent gate. |
GET /api/v1/public/summary returned in that request, and the scene is the real WebGL flight, drawn by software GL on a machine with no GPU. Each capture records the address it was taken from in docs/assets/screenshots/capture-report.json, so which build a picture came from is a fact in the repository rather than a claim about it. The further screens are vector renderings of the same interface, committed as SVG so they stay sharp at any zoom and cost a few kilobytes; they exist because the documentation pipeline has no signed-in session to photograph, not because the screens are imagined. Every one of them is drawn in the product's own palette, and that is asserted rather than assumed: `apps/web/test/brand-assets.test.ts` reads the drawings and fails on a colour from the palette this replaced β which is how nine of them were found still painted in the previous navy and blue, long after the stylesheets had changed.They are taken by
scripts/screenshots.mjs, which asserts the things a picture cannot show β the page read its figures from the API in that request, the WebGL scene drew a frame and reported six beats, the camera was at the beat each photograph is named for, the fly-through carries no text at all, the call to action reaches /login on desktop and on a phone, and nothing 4xx'd except the signed-out 401 β and then writes both the images and the report. Two of those checks exist because the pictures were nearly wrong in ways nobody could have seen: the GL flag combination this Chromium accepts is a narrower one, and the flags it had would have photographed the fallback without a scene in it; and a canvas is mounted on every route now, so the check that was meant to notice a scene that failed to draw was reading the cursor trail's 2D context instead of the scene. That same class of check is what found the missing tab icon: a browser's own request for /favicon.ico never appears as a page response.@inicio/engine β the same module the API runs β which is why the preview cannot disagree with the server and the sheet viewer cannot disagree with the file.One more screen: XLSForm import
The importer reports what it could not carry, and importing into a published form creates a new draft instead of rewriting the version that is already collecting.
The deployment is invite-only by design, so the tour below is the honest way in: sign-in is a one-time code, and the first thing the console offers is an invitation.
|
β±οΈ 30 seconds β orientation
β±οΈ 3 minutes β author
β±οΈ 5 minutes β collect
β±οΈ 2 minutes β analyse
|
π¬ A recorded tour is not committed yet. The two harnesses in # 35 end-to-end consumer checks + 28 offline-sync checks, against the running stack
pnpm e2e:consumer # 35 checks: the whole consumer journey
pnpm e2e:offline # 28 checks: the offline sync contractWant a video here? Drop a file into <!-- animated walkthrough -->

<!-- committed video, playable in the GitHub viewer -->
https://github.com/flawsom/Inicio/assets/<asset-id>
<!-- or an embed -->
[](https://www.youtube.com/watch?v=<video-id>) |
One deployable application with four deliberate layers. The API is a single Fastify app whose feature areas are modules, not microservices β which is why self-hosting it is one process and a database, not a cluster.
flowchart TB
subgraph clients["1 - Clients"]
direction TB
CONSOLE["Authoring console<br/>React 18, Vite 6"]
PWA["Collection PWA<br/>service worker, IndexedDB outbox"]
end
subgraph edge["2 - One origin"]
ORIGIN["Vercel deployment, SPA plus serverless entry<br/>or Caddy in front of Node - the same routes"]
end
subgraph app["3 - Fastify 5 application"]
direction TB
GATEWAY["gateway: session cookie, opaque link tokens, rate limit, helmet, CORS"]
MODULES["auth and OTP | organisations and members | forms, versions, publish<br/>distribution links | collection and sync | exports | analytics | legal"]
end
subgraph data["4 - Data and external services"]
direction TB
PG[("PostgreSQL 16<br/>ENABLE plus FORCE row level security")]
MAIL["Mail relay<br/>SMTP or HTTPS API"]
end
CONSOLE --> ORIGIN
PWA --> ORIGIN
ORIGIN --> GATEWAY
GATEWAY --> MODULES
MODULES --> PG
MODULES --> MAIL
PG -. "set_config app.org_id is transaction-local" .-> GATEWAY
| Layer | What lives there | Where |
|---|---|---|
| 1 Β· Clients | The authoring console and the installable collection app. Both call the same /api/v1 surface; neither holds a secret. |
apps/web |
| 2 Β· One origin | The SPA and the API share a hostname, so the session cookie is host-only and SameSite=Lax is enough. |
vercel.json, netlify.toml, docker/Caddyfile |
| 3 Β· Application | Authentication, organisations and RBAC, authoring and publishing, distribution, collection and sync, exports, analytics, legal. | apps/api/src/platform |
| 4 Β· Data | PostgreSQL 16 with row-level security per tenant; a mail relay for one-time codes and invitations. | apps/api/src/db/migrations, apps/api/src/mail |
sequenceDiagram
autonumber
participant D as Device, no signal
participant Q as IndexedDB outbox
participant API as Collection API
D->>Q: every keystroke persisted first
Q-->>D: Saved on this device at 14:22
Note over D,Q: hours may pass with the network down
D->>API: POST /collect/:token/responses with clientId
API-->>D: 201 created, or 200 with the same response id
D->>API: PATCH /collect/:token/responses/:clientId with answers and base
API-->>D: canonical answers, nulls for hidden questions, conflicts
D->>API: POST /collect/:token/responses/:clientId/submit
API-->>D: 200 submitted
Note over D,API: a retried submit is 409 already_submitted, read as success rather than loss
The merge rules. A sync carries base, the last server state the device saw, so the server can
tell what the device changed from what the server changed:
| Situation | Outcome |
|---|---|
| Only the device changed a question | The device's value is stored |
| Only the server changed it | The server's value is stored |
| Both changed it to the same thing | They converge |
| Both changed it differently | The server's value is stored; the device's value is retained in response_answer.device_value; the disagreement is recorded in response_conflict as device_server_mismatch |
| A calculation disagrees with the server's own computation | Stored, and flagged as calculation_mismatch |
| A question became irrelevant because logic hid it | Stored as null β "not asked" is a fact the codebook keeps |
A submission fails server-side re-validation (422) |
Demoted to an ordinary save, and the respondent is told what to fix |
-- 0003_platform.sql β the tenant helper every policy reads
CREATE FUNCTION app_is_member(target uuid) RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM membership
WHERE org_id = target
AND user_id = (SELECT NULLIF(current_setting('app.user_id', true), '')::uuid)
);
$$;
-- every tenant table, generated per table at migration time
ALTER TABLE form ENABLE ROW LEVEL SECURITY;
ALTER TABLE form FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY form_read ON form FOR SELECT USING (app_is_member(org_id));The application connects as a least-privilege role, and the organisation is a parameter of the transaction, set only after membership has been verified in that same transaction:
SELECT set_config('app.user_id', $1, true), set_config('app.org_id', $2, true);Three consequences worth stating plainly:
- Even a forged
app.org_idreturns nothing, because the policies readmembershiprather than trusting the setting. This is tested, not assumed. - A bypassing connection stops the process. On boot the API reads
pg_rolesforcurrent_user; a superuser orBYPASSRLSrole is refused in production with a message naming the fix. Managed Postgres hands you the owner's connection string by default β that is exactly how this bug happens. set_config(..., true)is transaction-local, so nothing leaks to the next request that checks out the same pooled connection.
|
π€ AI / ML β deliberately absent There is no model in the request path and no data leaves the deployment for training or inference. The roadmap tracks two additions, both opt-in and both off by default: AI-assisted authoring (draft a definition from a document) and analysis-time helpers. Until they exist and are configured, the product does not phone anywhere. See |
πΎ Cache, queue and object storage β provisioned, and load-bearing for uploads Valkey (cache/queue), MinIO (S3-compatible object storage) and ClamAV (upload scanning) are in the
self-hosted compose profile, and the attachment path is the one that uses them: point
An honest status beats a stack diagram that lies. |
git clone https://github.com/flawsom/Inicio.git
cd Inicio
corepack enable
pnpm install --frozen-lockfile |
cp .env.example .env
# minimum for a local run:
# DATABASE_URL the app role's connection
# MIGRATION_DATABASE_URL the owner/superuser connection (DDL only)
# OTP_PEPPER openssl rand -hex 32Full reference: environment variables. |
docker compose -f docker/docker-compose.yml \
up -d postgres valkey mailpitMailpit catches every one-time code locally (its inbox is on http://localhost:8025), so nothing leaves your machine. |
pnpm db:migrate
# the first account cannot be invited by anybody, so create it deliberately
pnpm --filter @inicio/api bootstrap:owner \
--email you@example.com --org Inicio
|
pnpm dev:api # Fastify on http://localhost:3001
pnpm dev:web # Vite on http://localhost:3000 (proxies /api to the API)Open http://localhost:3000, request a one-time code, read it in Mailpit, and you are in. |
pnpm lint # ESLint 9, flat config
pnpm typecheck # tsc across four workspace packages
pnpm test # 596 tests
pnpm coverage # v8 coverage, per package
pnpm build # engine -> api -> web
pnpm budgets # the built bundle, gzipped, against its ceilings
pnpm fabric:test # the Fabric deploy command's artifact and secret boundaries
pnpm superset:test # the free BI provisioning client, against a mock Superset
pnpm drift # is production running the migrations this checkout has? |
No Docker, no root? β the rootless development path
scripts/sandbox-setup.sh provisions the whole stack in user space: pnpm via corepack, and
PostgreSQL 16 from the upstream embedded binary build (chosen because, unlike most embedded wheels,
it ships contrib, so citext and pgcrypto are real and migrations run byte-for-byte as they do
against the postgres:16 image). It is idempotent, and it is what the verification runs in this
repository use:
bash scripts/sandbox-setup.sh # pnpm + postgres on 127.0.0.1:55432 + migrationsDocker compose: the full local stack
docker compose -f docker/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml ps| Service | Image | Port(s) | Role |
|---|---|---|---|
postgres |
postgres:16 |
5432 |
Database; docker/postgres-init seeds extensions and the app role |
valkey |
valkey/valkey:8-alpine |
6379 |
Cache and queue (provisioned; not in the request path yet) |
minio |
minio/minio |
9000, 9001 |
S3-compatible object storage; point AWS_ENDPOINT_URL_S3 at it to collect attachments locally |
clamav |
clamav/clamav:stable |
3310 |
Antivirus for uploads; heavy β start it only when you need it |
mailpit |
axllent/mailpit |
1025, 8025 |
SMTP catcher and web inbox, so local mail never escapes |
Caddy sits in front as the TLS-terminating reverse proxy in the self-hosted profile
(docker/Caddyfile), which is what makes the SPA and the API the same origin outside Vercel too.
Environment variables that will bite you
APP_DATABASE_URLwins overDATABASE_URLand must name the least-privilege role. A hosting provider's database integration usually claimsDATABASE_URLfor the owner connection β that is precisely the connection that would disable row-level security, so the API refuses to boot in production without a non-bypassing role.OTP_PEPPERmust be 32+ random bytes and must never change once in use: codes are hashed with it, so rotating it invalidates every outstanding code (which is sometimes what you want).COOKIE_SECURE=trueandAPP_ORIGIN=https://your.domainin production. The session cookie is host-only by design, so splitting the SPA and the API across two hostnames is a configuration change, not a flag.
Inicio
βββ apps
β βββ api Fastify 5 API β the whole server
β β βββ src/platform platform surface, mounted at /api/v1
β β β βββ auth.ts OTP sign-in, sessions, invitation redemption
β β β βββ admin.ts members, roles, invitations, mail diagnostics
β β β βββ orgs.ts organisations, membership, projects
β β β βββ forms.ts forms, versions, publish/restore, links, exports
β β β βββ collect.ts the respondent surface: responses, sync, submit
β β β βββ legal.ts, terms.ts policy text and consent with evidence
β β β βββ db.ts withUser / withOrg β the tenant transaction wrapper
β β β βββ events.ts the push channel: one server-sent event stream per form
β β β βββ public.ts the one unauthenticated route: live facts, no tenant data
β β β βββ guards.ts authentication and role guards
β β βββ src/db/migrations 0001 -> 0011, forward-only SQL
β β βββ src/auth, src/legal the Phase 1 school surface, kept working (/auth/*)
β β βββ src/db/pool.ts role check: refuses a superuser or BYPASSRLS connection
β β βββ src/mail SMTP and HTTPS relay abstraction
β β βββ scripts/bootstrap-owner.ts the first owner of an organisation
β β βββ test API tests, including the sync-contract suite
β βββ web React 18 + Vite 6 client
β βββ src/App.tsx the console: forms, builder, responses, quality, admin
β βββ src/studio.tsx the three-panel studio, in its own chunk (JSON Β· XLSForm Β· preview)
β βββ src/respondent.tsx the respondent's form, rendered once for the collection page and the studio
β βββ src/landing.tsx the public page: live figures, claims with evidence
β βββ src/immersive/instrument.ts the WebGL scene, its own chunk and its own quality tiers
β βββ src/realtime.ts EventSource client: coalescing, backoff, the offline verdict
β βββ src/router.ts the whole router: six paths and popstate
β βββ src/collect.tsx the collection page: definition cache, drafts, outbox (offline-first)
β βββ src/offline IndexedDB store, outbox queue, pure sync core
β βββ public/sw.js service worker: cache the shell, never the data
β βββ public/manifest.webmanifest and icons installable app
β βββ test offline core, service worker, realtime, studio, contrast and landing tests
βββ packages
β βββ engine canonical form model and everything derived from it
β β βββ src/expression.ts parser and validator for the expression grammar
β β βββ src/evaluate.ts relevance, constraints, calculations, choice filters
β β βββ src/adapters/xlsform.ts XLSForm <-> canonical model (round-trip fixpoint)
β β βββ src/compiler/docx-compiler.ts Word questionnaire -> canonical model, with a rule report
β β βββ src/adapters/xlsx.ts workbook reader and writer
β β βββ src/export dataset assembly, codebook, value labels, writers
β βββ ui reserved: shared components live in apps/web today
βββ api/ingest.ts Vercel serverless entry point (a translator, not a router)
βββ docker/ compose profile, Caddyfile, postgres init
β βββ superset/ pinned Apache Superset 6.1.0 stack for the free BI path
βββ docs/
β βββ ROADMAP.md verified status, what is missing, free-tier ceilings
β βββ POWER-BI.md live monitor, report plan and the PBIX/Fabric boundary
β βββ FREE-BI.md free-forever BI research, Superset stack and provisioning
β βββ MARKET-RESEARCH.md why the product is shaped this way
β βββ assets/*.svg the illustrations in this README
βββ specs/
β βββ PLATFORM.md canonical design (supersedes the school framing)
β βββ openapi.yaml the API contract
β βββ expression-grammar.md the grammar the engine implements
β βββ questionnaire.json the 2026 school ranking survey, as template #1
βββ scripts
β βββ sandbox-setup.sh rootless local stack (pnpm + embedded PostgreSQL 16)
β βββ e2e-consumer.mjs the whole consumer journey over HTTP, against a real database
β βββ e2e-offline.mjs the offline sync contract, conflicts included
β βββ netlify-deploy.sh publish the SPA to Netlify with the API proxied to one origin
βββ vercel.json one project: SPA and API, one origin
βββ netlify.toml SPA mirror, /api proxied to the same origin
βββ DECISIONS.md every significant choice, with the reasoning and the alternatives
βββ .env.example every variable, documented in place
Where to look first.
specs/PLATFORM.mdis the design.DECISIONS.mdis the why β including the bugs found in production and the reasoning that closed them.docs/ROADMAP.mdis the honest status, including what is deliberately not built.
Copy .env.example to .env. It is the authoritative list β this table is a map of it.
| Variable | Required | Default | Description |
|---|---|---|---|
NODE_ENV |
yes | development |
production enables the RLS role refusal and strict cookie behaviour |
PORT |
3001 |
API port | |
APP_ORIGIN |
yes | http://localhost:3000 |
The exact origin the browser uses; drives CORS and cookie policy |
| Variable | Required | Default | Description |
|---|---|---|---|
APP_DATABASE_URL |
yes in production | β | The application's connection β must be the least-privilege role. Takes precedence over DATABASE_URL |
DATABASE_URL |
yes | postgres://app_role:β¦@localhost:5432/inicio |
Fallback connection when APP_DATABASE_URL is unset |
MIGRATION_DATABASE_URL |
yes | postgres://postgres:β¦@localhost:5432/inicio |
Owner or superuser connection, used only for DDL by pnpm db:migrate |
DATABASE_SSL |
false |
true for Neon, Tiger Cloud and most managed Postgres |
|
APP_DB_PASSWORD |
app_dev_password |
The password migration 0002 gives the app_role |
Neon hands out two connections and names them by pooling, not by role: DATABASE_URL is the direct
endpoint, DATABASE_URL_POOLED the pooled one, and both belong to the database's owner. Pooling is not the
axis this platform cares about, so neither name is read as the application's connection. What matters is the
role, and it is set explicitly:
| Connection | Role | Endpoint | Why |
|---|---|---|---|
APP_DATABASE_URL |
app_role (the least-privilege role from migration 0002) |
pooled | short, frequent serverless invocations |
MIGRATION_DATABASE_URL |
owner | direct | DDL does not belong behind a transaction pooler |
An owner connection on Neon typically carries BYPASSRLS β this one does β so pointing
APP_DATABASE_URL at it would mean no tenant boundary in development and a refusal to boot in production.
app_role is created by migration 0002 with LOGIN and deliberately no password, so one step stays
with the operator, once per database:
ALTER ROLE app_role WITH LOGIN PASSWORD '<generated>';Put that connection in APP_DATABASE_URL and the owner's direct connection in MIGRATION_DATABASE_URL.
Both can be verified rather than assumed: SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user for the role, and pg_stat_activity for the connection the running API actually holds.
Behind those three names there is one database: this repository and the deployment name the same Neon
project, so a migration run here is a migration of production and pnpm drift compares production's own
ledger against main (Deployment). A workspace whose variables still name a different
database is a workspace whose pnpm db:migrate silently changes nothing that matters β which is the state
this project was in, and the reason pnpm drift reports the two readings side by side instead of trusting
the local one.
Because the suite reads the same variable names the application does, it owns a local database and says
so: apps/api/test/setup.ts refuses to run when either host is not this machine, so a workspace wired to a
deployment cannot have a test run create β¦_test on it and drop that schema. See Testing.
| Variable | Required | Default | Description |
|---|---|---|---|
OTP_PEPPER |
yes | dev placeholder | 32+ random bytes (openssl rand -hex 32); codes are hashed with it, so it must be stable |
OTP_TTL_MINUTES |
10 |
Lifetime of a one-time code | |
OTP_MAX_ATTEMPTS |
3 |
Attempts per code before it is dead | |
OTP_RESEND_COOLDOWN_SECONDS |
60 |
Minimum interval between code requests | |
SESSION_COOKIE_NAME |
sid |
Session cookie name | |
SESSION_IDLE_MINUTES |
480 |
Idle timeout | |
SESSION_ABSOLUTE_DAYS |
30 |
Hard expiry, regardless of activity | |
COOKIE_SECURE |
false |
true in production β requires HTTPS |
| Variable | Required | Default | Description |
|---|---|---|---|
SMTP_HOST, SMTP_PORT |
yes | localhost, 1025 |
Relay for codes and invitations (Mailpit locally) |
SMTP_SECURE |
false |
Implicit TLS, typically true with port 465 |
|
SMTP_USER, SMTP_PASS |
β | Credentials when the relay needs them | |
MAIL_FROM |
Inicio Survey <no-reply@β¦> |
Envelope sender. The live deployment sends as Inicio <inicio@unifies.codes>; it must be an address on a domain verified with the relay, which is a statement about sending and not about receiving |
|
MAIL_REPLY_TO |
β | Where a reply to one of these emails lands. Set it only to a mailbox somebody reads: the welcome invites a reply when it is set and offers an owner or an admin instead when it is not, because an instruction that bounces is worse than no instruction. This deployment sets it to the address it sends from β Inicio <inicio@unifies.codes> β so every message carries a reply target and its footer names one |
|
MAIL_TRANSPORT |
inferred | smtp or resend; inferred from RESEND_API_KEY when unset |
|
RESEND_API_KEY |
β | Choose the HTTPS API when the network refuses SMTP ports (25/465/587) | |
S3_BUCKET |
for file uploads | β | Where attachments live. With no bucket the upload control is not offered at all, and an image/audio/video/file question stays unanswered and says so |
S3_ENDPOINT |
β | For object storage that is not AWS and is reached by a bare endpoint | |
S3_REGION |
β | The region to sign for, when AWS_REGION is not the one in use |
|
AWS_ACCESS_KEY_ID |
for file uploads | β | The signer: used for the presigned PUT and for the API's own HEAD, GET and DELETE |
AWS_SECRET_ACCESS_KEY |
for file uploads | β | The signer's secret; it never leaves the server |
AWS_REGION |
β | The region bound into the signature | |
AWS_ENDPOINT_URL_S3 |
β | The standard AWS name for a custom endpoint, if that is how the host names it | |
UPLOAD_MAX_BYTES |
10485760 |
The ceiling for one attachment β checked when the upload is authorised, and again against the size the store itself reports | |
CLAMD_HOST |
β | The scanner the upload's scan step talks to. With no host, a file is recorded skipped, which is not the same as clean. CLAMD_PORT (3310) and CLAMD_TIMEOUT_MS (15000) have defaults |
|
ATTACHMENT_SCAN_POLICY |
allow_unscanned |
Whether an unscanned file may become an answer. allow_unscanned accepts a file whose scan was skipped or error β a field team whose scanner host is unreachable must not lose every upload β while the row keeps the two apart; require makes a scanner's verdict mandatory and refuses at authorisation with scan_required, before any URL is signed. The policy is reported to the client, so the page states it rather than assuming it |
|
PYREADSTAT_WORKER_URL |
β | The .sav/.dta worker (services/pyreadstat). With no URL those two exports refuse with a 501 that names this variable. PYREADSTAT_TIMEOUT_MS (20000) has a default |
|
RESEND_API_URL |
https://api.resend.com/emails |
Override for a compatible endpoint |
A relay will not send from a domain the account does not own, and the failure is worth
knowing in advance because it arrives as a refusal rather than a bounce. With Resend,
MAIL_FROM must be an address on a verified domain in that account: an unverified one is
rejected outright (This API key is not authorized to send emails from β¦), and the shared
onboarding@resend.dev sender is accepted but only reaches the account's own address. Neither
is a guess β both are what the provider answered when asked. GET /api/v1/auth/mail-status (and the
sign-in screen, which is built on it) reports the same evidence rather than an assumption:
a probe, a message the relay actually accepted, or nothing yet. On a serverless deployment that last
one resets with every cold start, so basis: none there means "nothing has been sent by this
instance", which is a different thing from a relay that refused.
A verified domain is verified for sending, which is not the same as having an inbox. The four
emails this product writes β the sign-in code, the welcome, the sign-in notice and the submission
receipt β are apps/api/src/mail/templates.ts, and the identity they leave under is MAIL_FROM.
A sender does not need a mailbox; a reply target does, and the two are separate variables for that
reason. The delivery path itself was verified against the live relay rather than assumed: all four
went out through Resend from inicio@unifies.codes and arrived in a mailbox with their links, both
alternatives and their palette intact (26/26 content checks, 4/4 reported delivered).
What an email looks like is a surface, not a fallback. Each message opens on the product's own dark
material β a band carrying the kind of message and the one fact that belongs beside it, the only element in
an otherwise white document that is not white β and closes with the mailbox it left from, read out of
MAIL_FROM so the line cannot claim an identity the relay did not use. The sign-in code states when it was
requested, from which address, and when it expires, the last of those computed from OTP_TTL_MINUTES rather
than written beside it. And because these subjects repeat by design, every message is stamped with its own
X-Entity-Ref-ID on both transports, so a second code cannot arrive folded behind the first in a client
that groups by subject. Measured in a real browser rather than read as a string β 24 renders (4 messages Γ
2 colour schemes Γ 3 viewports) checked for sideways overflow, a band that separates from its card, a code
that fits its panel and stays legible on a phone, and every text/background pair read from
getComputedStyle β by apps/api/.run/render-mail.ts, with the structure pinned by
apps/api/test/mail-templates.test.ts. The sign-in screen states the same two facts to the person about to
look for the message β which mailbox it comes from, and how long the code lives β and takes both from
GET /api/v1/auth/mail-status rather than from its own source, so the page, the CSS-free text part and the
footer of the message cannot name two different senders or promise two different lifetimes. See
DECISIONS.md D49.
Both ceilings are counted in PostgreSQL (rate_limit_counter, migration 0011), not in a process's memory,
because the API runs as a serverless function: a per-process limiter is reset by every recycled instance, so
the ceiling a client is told about would really be "per process" and a flood would simply spread across them.
One atomic upsert per request answers both how many requests this subject has made in this window and when the
window ends, from state every instance shares. See apps/api/src/lib/rate-limit.ts.
| Variable | Required | Default | Description |
|---|---|---|---|
RATE_LIMIT_MAX |
120 |
Requests per minute per address, for every route | |
COLLECT_RATE_LIMIT_MAX |
1200 |
Requests per minute per distribution link, on the public collection routes, on top of the per-address ceiling |
An over-limit request is a 429 carrying retry-after, and every limited response carries x-ratelimit-limit,
x-ratelimit-remaining and x-ratelimit-reset (both measured, not assumed). Three properties are decisions
rather than details. The counters hold no address and no token: a bucket's subject is hashed with the
deployment's own pepper and only the namespace (ip, link) stays in the clear, so the table cannot be read
back as a log of who was throttled while an operator can still group by what was hit. A limiter that cannot
reach the database fails open, with one log line a minute naming the namespace and the error: the limiter is
a throttle, and the authorisation boundary is the session, the scope and the row-level policies, so an
unreachable counter must not become a total outage. Two registrations, two counters, both consulted: the
per-address ceiling covers every route, and public collection adds one keyed on the distribution link, which is
both what a flood targets and the unit a field team shares, so one flooded link cannot lock out another team on
the same form. Neither is a plan limit; collection volume is never a billing event.
S3-compatible, and optional on purpose. With no bucket configured the API answers 501 storage_not_configured and the renderer goes on saying those questions are not collected here β a
supported state for a deployment that has nowhere to put a file, not a broken one.
| Variable | Required | Default | Description |
|---|---|---|---|
S3_BUCKET |
for uploads | β | Bucket that holds answer attachments |
AWS_ENDPOINT_URL_S3 |
for uploads | β | Path-style S3-compatible endpoint; S3_ENDPOINT overrides it |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
for uploads | β | Used to sign a URL and nothing else; they never reach a browser |
AWS_REGION |
us-east-1 |
Signing region; S3_REGION overrides it |
|
UPLOAD_MAX_BYTES |
10485760 |
Ceiling for one attachment, checked when the upload is authorised and again against the size the store reports | |
CLAMD_HOST |
β | Scanner for uploaded bytes. Unset means nothing is scanned and every file records skipped |
|
CLAMD_PORT, CLAMD_TIMEOUT_MS |
3310, 15000 |
clamd INSTREAM port, and how long its verdict is awaited |
The allowlist of file types is per question type and lives in the API
(apps/api/src/storage/limits.ts), not in the browser. What scan_status says is what happened:
clean means a scanner read the bytes, infected means it named something and the object was deleted,
error means a configured scanner did not answer, and skipped means there is no scanner here at all.
| Variable | Required | Default | Description |
|---|---|---|---|
REDIS_URL |
redis://localhost:6379 |
Valkey; the product runs without it today | |
FABRIC_TENANT_ID, FABRIC_CLIENT_ID, FABRIC_CLIENT_SECRET |
for fabric:deploy |
β | Service principal for publishing a PBIX or a PBIR report to a Fabric workspace. Never read by the web bundle |
FABRIC_WORKSPACE_ID, FABRIC_SEMANTIC_MODEL_ID |
for fabric:deploy |
β | Default workspace and the semantic model a PBIR report binds to |
SUPERSET_URL, SUPERSET_USERNAME, SUPERSET_PASSWORD |
for superset:provision |
http://localhost:8088, admin, β |
Superset endpoint and admin login used by the CSV upload. The stack's own secrets live in the gitignored docker/superset/.env |
ADMIN_EMAIL, ADMIN_PASSWORD |
for admin:create |
β | Used once by the Phase 1 admin bootstrap; the password must be at least 12 characters |
INVITE_ONLY |
true |
false opens self-registration. Leave it true unless you intend an open deployment |
.env. .gitignore covers .env, .env.* and !.env.example β and
the API logs no secret material, including one-time codes.
https://inicio.unifies.codes/api/v1 # the live deployment
http://localhost:3001/api/v1 # local API
http://localhost:3000/api/v1 # through the Vite proxy β the same origin the browser uses
The contract lives in specs/openapi.yaml, and it is held to the code:
apps/api/test/openapi.test.ts parses it, reads the route registrations out of the source, and fails
the suite for a route with no entry and for an entry whose route is gone.
| Surface | Credential | Notes |
|---|---|---|
| Console (authoring, admin, exports) | sid session cookie |
Obtained from a one-time code. httpOnly, Secure, SameSite=Lax, host-only β which is why the SPA and the API share an origin. |
| Collection (respondents) | Opaque token in the path | POST /collect/:token/responses. The token is 32 CSPRNG bytes returned once and stored only as a hash. No account, no cookie. |
| Machine clients (an export job, a QC pipeline, a house dashboard) | Authorization: Bearer ik_β¦ |
An API key is a restricted session for its creator: it acts only inside the organisation it was issued for, only at a route that declares one of its scopes, and it stops working the moment that person leaves the team. The secret is 32 random bytes, returned once by the create call and stored only as sha256(secret). |
Status vocabulary β errors are machine-readable and stable, not prose:
| Code | Errors |
|---|---|
400 |
invalid_request, unknown_format, acceptance_required |
401 |
unauthenticated, invalid_api_key (unknown, expired or revoked), invalid_otp (expired, mismatch, attempts_exceeded) |
403 |
forbidden, read_only_role, terms_required, key_not_permitted (this route is session-only), insufficient_scope (naming the scope the key lacks), key_org_mismatch (a key pointed at another organisation) |
404 |
not_found, link_not_found, unknown_template β a non-member gets 404, not 403, because whether an organisation exists is not a member's business to probe |
409 |
already_submitted, client_id_conflict, not_published, version_immutable, version_archived, last_owner |
410 |
link_expired, link_already_used, link_exhausted, link_inactive |
422 |
invalid_definition β publication is where authoring errors become a blocked publish instead of a broken form mid-interview |
501 |
format_not_available β a statistical export was asked for and this deployment has no worker configured, naming PYREADSTAT_WORKER_URL. Nothing is written |
502 |
export_worker_unavailable β the worker is configured and did not answer, so no file was produced |
Machine access is deny-by-default, and that is the design. A key reaches a route only when that route declares the capability it needs, so the machine surface is a list you can read rather than the absence of a check you cannot see. The scopes, and what each one buys:
| Scope | Routes it opens |
|---|---|
forms:read |
GET /forms, GET /forms/:formId, GET /forms/:formId/versions/:versionId, GET /forms/:formId/xlsform |
responses:read |
GET /forms/:formId/responses |
analytics:read |
GET /forms/:formId/analytics |
responses:export |
GET /forms/:formId/responses/export |
flags:read |
GET /orgs/:orgId/flags |
flags:write |
PATCH /orgs/:orgId/flags/:flagId, POST β¦/flags/scan, POST β¦/responses/:responseId/flags |
GET /api-scopes marks each scope with whether a route declares it today, and the console will not offer one that no route declares β because a key granted only vocabulary is a key that can call nothing. Three consequences worth stating, because each one is a property of the route table rather than a promise: a key can never mint or revoke another key; it can never publish a form or move a distribution link; and it can never change where an organisation's data is sent. It also only ever reads its own organisation β a key for A is 403 key_org_mismatch against B's form even when its creator is a member of both, and GET /forms is narrowed to that organisation in SQL rather than filtered afterwards.
Authentication, invitations and consent
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/auth/otp/request |
β | Request a one-time code. Always answers { "status": "sent" }; an unknown address is a 404 and never a special case |
POST |
/auth/otp/verify |
β | Exchange a code for a session cookie |
GET |
/auth/me |
cookie | Identity plus the organisations the caller can see |
GET |
/auth/session |
cookie | The subject behind the current session |
POST |
/auth/logout |
cookie | Revoke the session (204) |
GET |
/invite/:token |
β | Look at an invitation without spending it |
POST |
/invite/redeem |
β | Spend it: creates the membership the invitation names |
GET |
/legal/terms, /legal/privacy |
β | The exact text, with its version and hash |
GET |
/auth/terms/status |
cookie | Whether this account has accepted the current text |
POST |
/auth/terms/accept |
cookie | Record acceptance with evidence |
GET |
/mail-status |
β | Whether a relay is configured, without leaking how |
Organisations, members and invitations
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET / POST |
/orgs |
cookie | List organisations, or create one (the creator becomes owner) |
GET / PATCH |
/orgs/:orgId |
cookie + role | Read or update an organisation |
GET / POST |
/orgs/:orgId/members |
cookie + role | List members, or add one |
PATCH / DELETE |
/orgs/:orgId/members/:userId |
owner/admin | Change a role or remove a member (409 last_owner protects the last owner) |
GET / POST |
/orgs/:orgId/invites |
admin | List or issue single-use invitations |
DELETE |
/orgs/:orgId/invites/:inviteId |
admin | Revoke an invitation (immediately) |
GET / POST |
/orgs/:orgId/projects |
cookie + role | Projects: the seam a workspace layer will grow into |
GET |
/orgs/:orgId/diagnostics/mail |
owner | Why mail is, or is not, flowing |
Forms, versions and publishing
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET / POST |
/forms |
cookie + role Β· key forms:read (GET only, own organisation only) |
List forms (with published version and response state), or create one from JSON, .xlsx or .docx |
GET / PATCH |
/forms/:formId |
cookie + role Β· key forms:read (GET only) |
Read a form with its versions, or update it |
POST |
/forms/:formId/versions |
member+ | Create a new draft version |
GET / PUT |
/forms/:formId/versions/:versionId |
member+ Β· key forms:read (GET only) |
Read or replace a draft definition |
POST |
/forms/:formId/versions/:versionId/publish |
admin+ | Publish β validated first, then frozen |
POST |
/forms/:formId/versions/:versionId/restore |
admin+ | Fork an old version into a new draft |
POST |
/forms/:formId/import |
member+ | Compile an XLSForm workbook or Word questionnaire and report what it could not carry |
GET |
/forms/:formId/xlsform |
member+ Β· key forms:read |
Export the form back to XLSForm |
GET |
/templates |
cookie | Template library (the school ranking survey is #1) |
Distribution, collection, responses and analytics
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET / POST |
/forms/:formId/links |
admin+ | List or mint distribution links (anonymous, single_use, multiple, panel) |
DELETE |
/forms/:formId/links/:linkId |
admin+ | Revoke a link |
GET |
/collect/:token |
token | The published definition a respondent sees, cached for offline use |
POST |
/collect/:token/responses |
token | Start or resume by device UUID. 201 when created, 200 with the same id when retried |
GET / PATCH |
/collect/:token/responses/:responseId |
token | Read or sync answers ({ answers, base }) |
POST |
/collect/:token/responses/:responseId/submit |
token | Submit; a retry answers 409 already_submitted |
GET |
/forms/:formId/responses |
cookie + role Β· key responses:read |
Response list with per-response conflict counts |
GET |
/forms/:formId/analytics |
cookie + role Β· key analytics:read |
Live tallies, status counts, conflicts, daily activity |
GET |
/forms/:formId/events |
cookie + role | The push channel: a server-sent event stream naming the aggregates that changed, coalesced per tick. Session-only, because EventSource cannot send an Authorization header |
GET |
/forms/:formId/responses/export?format=csv|xlsx|json |
cookie + role Β· key responses:export |
Exports; csv returns a zip bundle with data, codebook, value labels and missing codes |
Integrations: API keys, webhooks and the quality queue
| Method | Path | Credential | Scope | Purpose |
|---|---|---|---|---|
GET |
/api-scopes |
cookie | β | The scope vocabulary, with a flag on each saying whether a route declares it today |
GET / POST |
/orgs/:orgId/api-keys |
cookie | β | List keys (never a secret), or mint one: the secret is in the create response and nowhere else, because only its hash exists |
POST |
/orgs/:orgId/api-keys/:keyId/rotate |
cookie | β | Issue the key's successor and revoke it in one transaction: same creator, same scopes, same expiry, a new secret returned once, and the old one refused on its very next request |
DELETE |
/orgs/:orgId/api-keys/:keyId |
cookie | β | Revoke. The next request with it answers 401 invalid_api_key; the second revoke answers 404 |
GET |
/webhook-events |
cookie | β | The event catalogue and what each event means |
GET / POST |
/orgs/:orgId/webhooks |
cookie | β | List endpoints with their pending and failed counts, or create one β HTTPS only, enforced by a database constraint, with a signing secret returned once |
PATCH / DELETE |
/orgs/:orgId/webhooks/:endpointId |
cookie | β | Change, pause, re-enable or delete an endpoint (and its history) |
GET |
/orgs/:orgId/webhooks/:endpointId/deliveries |
cookie | β | Delivery history: status, attempts, the receiver's status, the error |
POST |
/orgs/:orgId/webhooks/:endpointId/test |
cookie | β | Sign and send a synthetic event now and report the receiver's real answer, including its failure status |
POST |
/orgs/:orgId/webhooks/deliveries/:deliveryId/retry |
cookie | β | Requeue one delivery and drain, so "did it work this time" is the reply |
GET |
/flag-kinds |
cookie or key | β | The flag vocabulary and the thresholds, in words |
GET |
/orgs/:orgId/flags |
cookie or key | flags:read |
The queue (status, formId, limit) with counts by status and by kind |
PATCH |
/orgs/:orgId/flags/:flagId |
cookie or key | flags:write |
Resolve or dismiss, with the note that records why |
POST |
/orgs/:orgId/forms/:formId/flags/scan |
cookie or key | flags:write |
Detect exact duplicate submissions. Idempotent: a finding is recorded once, however often you scan |
POST |
/orgs/:orgId/forms/:formId/responses/:responseId/flags |
cookie or key | flags:write |
Raise a flag by hand, with a severity and a note |
A webhook delivery carries X-Inicio-Timestamp and X-Inicio-Signature: sha256=<HMAC-SHA256 of "${timestamp}.${body}">, signed with the endpoint's own secret β the construction Stripe and GitHub use, and for the same reason: the timestamp is inside the signed material, so a captured request cannot be replayed later. Verify it against the raw body, before parsing. Deliveries are enqueued in the same transaction as the event they describe, which is what makes an event exactly-once with the data: a publish that committed has delivery rows and a publish that rolled back has none. Delivery itself is at-least-once against a stable delivery id, and after five consecutive failures an endpoint disables itself and says why, because an endpoint that has failed two dozen times is usually a deleted receiver.
The public surface
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/public/summary |
β | The one unauthenticated route. Server version and uptime, the measured round trip of the query that produced the response, the migration ledger, and the template library summarised by walking the real definitions. No form, response, organisation or count of any of them. Never stored |
API=https://inicio.unifies.codes/api/v1
# 1 - a one-time code (read it in Mailpit locally, or in your inbox in production)
curl -sS -X POST "$API/auth/otp/request" \
-H 'content-type: application/json' \
-d '{"email":"you@example.com"}'
# -> 200 {"status":"sent"}
# 2 - exchange it for a session cookie
curl -sS -c cookies.txt -X POST "$API/auth/otp/verify" \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","code":"123456"}'
# -> 200 {"subject":"user:..."} and Set-Cookie: sid=...
# 3 - accept the terms (required before the workspace opens)
curl -sS -b cookies.txt -X POST "$API/auth/terms/accept" \
-H 'content-type: application/json' \
-d '{"termsVersion":"2026-01","privacyVersion":"2026-01","accepted":true}'
# -> 200 {"accepted":true,"termsVersion":"2026-01"}
# 4 - create an organisation (the creator is its owner)
curl -sS -b cookies.txt -X POST "$API/orgs" \
-H 'content-type: application/json' -d '{"name":"Inicio"}'
# -> 201 {"organisation":{"id":"...","name":"Inicio","slug":"inicio"}}
# 5 - create a form from a schema
curl -sS -b cookies.txt -X POST "$API/forms" \
-H 'content-type: application/json' \
-d '{"orgId":"<org-uuid>","name":"Household panel","definition":{ ... }}'
# -> 201 {"form":{"id":"..."}}
# 6 - publish a version, then mint a distribution link
curl -sS -b cookies.txt -X POST "$API/forms/<form-uuid>/versions/<version-uuid>/publish"
# -> 200 {"version":{...},"report":{"errors":[],"warnings":[...]},"alreadyPublished":false}
curl -sS -b cookies.txt -X POST "$API/forms/<form-uuid>/links" \
-H 'content-type: application/json' -d '{"kind":"anonymous"}'
# -> 201 {"link":{...},"token":"...","collectPath":"/api/v1/collect/..."}This is the exact sequence the collection app performs, and the sequence the offline harness asserts.
TOKEN=<the-token-from-the-link>
CLIENT=$(uuidgen | tr 'A-Z' 'a-z') # the device invents the response id
# start: the same call twice is the same response, never two
curl -sS -X POST "$API/collect/$TOKEN/responses" \
-H 'content-type: application/json' -d "{\"clientId\":\"$CLIENT\"}"
# -> 201 {"responseId":"$CLIENT","created":true}
# -> 200 {"responseId":"$CLIENT","created":false} <- the retry
# sync: `base` is the last server state this device saw
curl -sS -X PATCH "$API/collect/$TOKEN/responses/$CLIENT" \
-H 'content-type: application/json' \
-d '{"answers":{"score":20},"base":{"score":3}}'
# -> 200 {"saved":1,"answers":{"score":20},"conflicts":0,"missing":[],"invalid":[]}
# submit: and if the connection dies, submit again - that is not an error
curl -sS -X POST "$API/collect/$TOKEN/responses/$CLIENT/submit" \
-H 'content-type: application/json' -d '{"answers":{"score":20},"base":{}}'
# -> 200 {"responseId":"...","status":"submitted","answered":1,"conflicts":0}
# -> 409 {"error":"already_submitted"} <- read as success, never as lossA conflict, observed rather than described. Two devices edit the same field from the same base:
SELECT value, device_value FROM response_answer WHERE response_id = '...'; -- 20 | 9
SELECT kind FROM response_conflict WHERE response_id = '...'; -- device_server_mismatch|
A definition is ordinary JSON {
"title": "School Ranking Survey 2026",
"defaultLanguage": "en",
"languages": ["en"],
"questions": [
{
"id": "q_enrolment",
"name": "enrolment",
"type": "integer",
"label": { "en": "Enrolment (students)" },
"validation": { "constraint": ". >= 0" }
},
{
"id": "q_secondary",
"name": "secondary_details",
"type": "text",
"label": { "en": "Secondary details" },
"rules": { "visibleIf": "${level} = 'secondary'" }
}
],
"choiceLists": {}
}The same object is what the analyser validates, what the engine evaluates in the browser, what XLSForm imports and exports, and what the export pipeline reads to build a codebook. |
Expressions the engine actually supports import { parseExpression, evaluate } from '@inicio/engine';
const expression = parseExpression('${enrolment} > 0');
const relevant = evaluate(expression, { enrolment: 1248 }); // trueExpressions are parsed and validated at publish time, so a typo is a blocked publish with a question name in it β never a blank field in front of a respondent. |
|
The offline queue, one deep and durable // every keystroke goes to IndexedDB *before* anything is sent
await recordAnswers(store, linkToken, next, [name]);
// a save supersedes the previous save; a submit supersedes a pending save
await requestSubmit(store, linkToken, next, Object.keys(next));
// the draft is readable from the device alone: no session, no connection
const copy = buildBackup(draft, { title, version }); // Save a copy
const { warning } = storageHealth(await navigator.storage.estimate());A long outage therefore cannot build a backlog of stale payloads: the queue holds the latest intent, not a history of intentions. The other half of the same contract is on the server: |
Exports are pipelines, not dumps # data + codebook + value labels + missing codes, zipped
curl -sS -b cookies.txt \
"$API/forms/<uuid>/responses/export?format=csv" -o export.zip
curl -sS -b cookies.txt \
"$API/forms/<uuid>/responses/export?format=json" | jq '.data[0]'{
"data": [ { "response_id": "...", "score": 20, "secondary_details": null } ],
"codebook": [ { "name": "score", "type": "integer", "label": "Score" } ],
"valueLabels": [ { "name": "level", "value": "secondary", "label": "Secondary" } ]
}A skipped question is exported as |
Method: 12 sequential requests per path from a single VM over the public internet, 0.6 s apart,
time_starttransfer in milliseconds, 13 October 2026, against the deployment this work produced.
Reproduce it:
for i in $(seq 1 12); do
curl -sS -o /dev/null -w "%{time_starttransfer}\n" https://inicio.unifies.codes/
sleep 0.6
done| Path | What it measures | Median | p95 | Notes |
|---|---|---|---|---|
/ (HTML shell) |
Edge cache and static shell | 91 ms | 110 ms | max 160 ms |
/assets/index-*.js |
The whole app: 222.88 kB, 70.87 kB over the wireΒΉ | 93 ms | 113 ms | max 162 ms |
/assets/landing-*.js |
The landing page, after first paint: 16.20 kB, 5.56 kB over the wire | 92 ms | 102 ms | max 105 ms |
/assets/instrument-*.js |
three.js, the landing page only: 533.48 kB, 135.76 kB over the wire | 92 ms | 100 ms | max 100 ms |
/api/v1/legal/terms |
Function, no database round trip | 92 ms | 99 ms | max 171 ms |
/api/v1/public/summary |
Function with a database round trip, never cached | 395 ms | 471 ms | one cold start at 1 780 ms in this sample |
/health |
Function with a database round trip | 393 ms | 412 ms | max 420 ms |
ΒΉ The three asset rows measure the build that was deployed, which predates the integration and quality screens; the current tree compiles to a 218.6 kB critical app chunk and 67.9 kB over the wire (see Bundle size), and the latency is a property of the host and the request, not of a particular hash.
The honest reading of that table: the gap is not between static assets β 222.88 kB of app and 533.48 kB of three.js cross the wire in the same ~92 ms, because they are served from the edge and the network is not the constraint. It is between the static rows and the last two, and it is geography rather than code: the serverless function's round trip to PostgreSQL, which is 300 ms of the 395. That is also the number that matters most for feel: the ambient field and every entrance animation run on the device, but nothing that reads the database can appear before its round trip, so the function region and the database region being co-located is the single largest perceived-speed win available here, and it is a hosting setting rather than a code change.
403 and back off.
Generated by pnpm build β the entire frontend, with no framework runtime beyond React:
The console and the landing page are separate chunks, and that separation is the point: three.js is only ever fetched by a reader who is looking at the landing page.
pnpm budgets measures every one of these against a ceiling and fails the build when one is crossed, so
the table below is a budget rather than a report:
| Asset | Raw | Gzip | Budget | Loaded by |
|---|---|---|---|---|
index.html |
2.8 kB | 1.3 kB | β | everyone |
index-*.css (the whole app) |
30.2 kB | 6.5 kB | 7 kB | everyone |
index-*.js (console, collection, admin, legal) |
218.6 kB | 67.9 kB | 80 kB | everyone |
landing-*.css |
13.7 kB | 3.6 kB | 6 kB | the landing page |
landing-*.js |
18.8 kB | 6.6 kB | 8 kB | the landing page |
monitor-*.js (the live monitor, CSV export and report plan) |
28.9 kB | 9.4 kB | 150 kB | the live monitor tab |
instrument-*.js (three.js, the flight) |
477.6 kB | 122.2 kB | 150 kB | the landing page, after first paint |
cursor-trail-*.js |
2.8 kB | 1.4 kB | 4 kB | every route, after first paint |
The console bundle is 218.6 kB raw, which carries the push channel, the console β including the integration, quality and raise-a-finding screens β and an ambient light field of its own, so the signed-in app is recognisably the same product without a scene anywhere near the critical path. There is no charting library, no UI kit, no state manager and no icon package: the console is hand-built React with CSS custom properties, which is why the whole app is smaller than a typical component library's stylesheet. The scene is in its own dynamically imported chunk, so a signed-in user heading for the console never downloads three.js, and the WebGL code is not in the critical path of the page that does. The ambient field is the deliberate other half of that decision: it draws the same structure as the sculpture in about 9 kB of hand-written canvas code rather than 135 kB of renderer, because a person reading a table of responses should not be paying for a 3D engine to look at a background.
| Metric | Value |
|---|---|
| Migrations | 11, forward-only, 0001 to 0011 |
Tables under FORCE ROW LEVEL SECURITY |
14 tenant tables, with per-table policies generated at migration time, and the forged-org_id isolation test enumerating every one of them |
| Tenant context | set_config('app.user_id' | 'app.org_id', β¦, true) β transaction-local, no pooling leak |
| Production | Neon Postgres, free tier; the app connects as app_role, never the owner |
Not measured yet, and deliberately left empty rather than filled with a number nobody can reproduce. Running Lighthouse against a deployment whose sign-in requires a one-time code means scripting the auth flow first, which is on the roadmap under verification debt rather than pretending the score is 100. What can be verified today is verifiable: the checks in Testing, the bundle sizes above, and the fact that the app shell paints from the edge.
pnpm lint # ESLint 9, flat config β 0 errors
pnpm typecheck # tsc across engine, api, web and the serverless entry
pnpm test # 596 tests
pnpm coverage # v8 coverage, per package
pnpm build # engine -> api -> web
pnpm budgets # the built bundle, gzipped, against its ceilings
pnpm fabric:test # the Fabric deploy command's artifact and secret boundaries
pnpm superset:test # the free BI provisioning client, against a mock Superset
pnpm drift # is the deployment running the migrations this checkout has?
# the whole statistical chain β engine -> payload -> a real pyreadstat worker -> pyreadstat
# reading the file back. Needs Python with services/pyreadstat/requirements.txt installed,
# which is why it is not part of the Node-only CI:
pnpm --filter @inicio/api roundtrip:statistical
pnpm icons # regenerate the app icons from the palette β committed output, not edited| Suite | Tests | Focus |
|---|---|---|
packages/engine |
165 | Expression parsing and evaluation, validation, XLSForm round-trip fixpoint, the Word .docx questionnaire compiler (metadata, statement grids, brand cascades, rankings and rotations), XLSX read/write, dataset and codebook assembly, every export writer, the workbook the studio shows (xlsFormSheets asserted row by row against the file exportXlsForm zips, so the viewer and the download cannot drift), the version diff (23 tests asserting the sentences themselves β a per-language label change, a choice added and removed, mandatory flipping, a reorder, a repeat flag, and the case where a question's options move between shared lists, where the option sets are deliberately not compared with each other), and the body the statistical worker receives: statisticalPayload held to a real buildDataset β every column's prompt, the rows cell by cell including a blank that is neither 0 nor the word refused, the value labels, the missing table's meanings, and a Hindi label surviving byte for byte |
apps/api |
199 | Auth and OTP, invitations (single-use, expiry, revocation), RBAC, tenancy with a forged org_id, publish and restore immutability, the version comparison over the real database (a form published, a second version forked from it and edited, then a third restored from the first one: the comparison follows based_on to v1 rather than to the v2 numbered before it and reports the definitions identical, ?against= gives the other answer in words, and a first version answers null rather than an empty comparison), and the statistical export at its own boundary: 501 naming PYREADSTAT_WORKER_URL with no worker, the worker's bytes returned unchanged with its x-inicio-notes read back, 502 rather than a file when the worker cannot be reached or answers with nothing, and β with &codebook=1 β one archive holding the data file the worker wrote plus the codebook, value labels, missing table and metadata, and not the data a second time, and the contract itself: the route table, specs/openapi.yaml and the machine surface held equal in both directions, so a route cannot ship undocumented or a scope unstated, the sync contract: idempotent start, base-aware merge, conflict retention, 409 already_submitted, re-validation nulling irrelevant answers, and the push channel over a real socket: ready first, silence while nothing changes, then one coalesced event naming the aggregate that moved, with a 404 and no stream for a form the caller cannot see, plus the public summary: read live, and asserted key by key to carry no tenant data, and the integration surface: a key whose secret is shown once and stored as a hash, its scope enforced at the route that declares it and named when it is missing, a key refused across organisations even though its creator belongs to both, revocation effective on the very next request, every refusal by an actor the code can name written to the audit trail (access.denied, with the code, the method, the path, the organisation and the granted scopes, and nothing for an anonymous 401 because there is nobody to attribute it to), rotation that keeps the grant and changes only the secret (the successor's user_id, scopes and expiry equal the previous key's, the audit row names both prefixes and neither secret, an admin cannot rotate a key onto themselves, a member cannot rotate at all, an already-revoked key is 404 rather than rotated, and a key whose creator has left the organisation is refused with 409 creator_not_a_member and nothing changed β the revocation rolled back with the refusal), an endpoint that disables itself after five consecutive failures, a delivery signature checked against an independent HMAC in the test, a duplicate scan that refuses to record the same finding twice, and the read scopes: a key granted forms:read reaches the forms list narrowed to its own organisation, is 403 key_org_mismatch on another organisation's form even though its creator belongs to both, and is told it is missing responses:export when it tries to export with only responses:read, and the emails: the sign-in code is in the body and never in the subject, no template fetches anything (no image, no pixel, no web font, no url()), every colour pair it uses is measured at 4.5:1 in both schemes, a hostile organisation name cannot close an attribute or reach the hidden preheader, the text alternative is prose rather than tags with the tags removed, the header a transport composes is the one the environment named, the surface every message now opens on (the band carrying the kind of message and its one fact, present in all four templates), the record the code email carries (the instant it was requested, the address it came from, and an expiry computed from OTP_TTL_MINUTES rather than typed beside it β and the panel omitted entirely, with the lifetime still stated, when a caller knows neither), the line every footer closes with (the mailbox read out of MAIL_FROM, and a reply clause that appears only when MAIL_REPLY_TO names one), and the header that keeps two codes out of one thread, asserted in the composed nodemailer message and in the body posted to the relay, with two sends required to carry different ids, and the durable rate limiter: two store objects over one database counting the same bucket, ten concurrent increments landing as ten rather than losing an update, an expired window taken over in place instead of accumulating a row per window, a salted hash on disk and never the address or the token, a ceiling that still refuses on a freshly built app instance that has counted nothing itself (what the store this replaced could not do), one link's flood leaving another link alone, the sweep deleting expired windows and leaving live ones, and a store that cannot reach PostgreSQL failing open with one line saying so β plus the wiring: only the collection routes touch the link namespace, and the token reaches a counter's key without ever landing in it |
apps/web |
232 | The offline core (enqueueSave/enqueueSubmit supersede rules, the backoff schedule, and the resume rule that keeps the device's copy while anything is pending), the device's own copy of its answers (the draft exported to a file and restored onto an empty device, which then sends the submission it had been holding on the same response id; a copy of another link, of a newer app, or older than what is already on the device each refused in a sentence), the three ways a send can fail kept apart (a file the network ate held and re-sent by itself after a reconnect, a file the server refused never retried, and a queue that names its attempt count, its last failure and when it will try again β with the collection page itself mounted over a stubbed wire, including the answers still there when the page is opened a second time and a device warned before a full disk costs anything), the service worker's routing executed in a VM, the pointer trail (mounted exactly once on each route by the shell, mounting the real effect through a recorded 2D context so one route cannot become two trails under StrictMode, and removed by reference when the route goes β and not even fetched by a device that would never draw it, asserted by counting the mocked module's own evaluation rather than by trusting the bundler), the sitemap and robots.txt (exactly the four public routes on the canonical origin, no lastmod, the two hosting catch-alls asserted not to be able to answer either file with the app shell β the /sw.js defect in a new dress β and every URL-valued redirect target in netlify.toml required to be on that same canonical origin, so the deploy config cannot point at a platform hostname the docs deny exists), the realtime client (a burst inside one frame becomes one notification, an unknown aggregate is dropped, repeated failures end in offline), the landing page mounted in jsdom (its figures come from the API, it is readable when WebGL is unavailable, and it shows nothing rather than something old when the API does not answer), and the six-beat flight's own rules β the choreography as arithmetic with no renderer in sight (the camera's pose at any progress, the disconnected device's delivered share never decreasing while it hands over, the bank returning to level by the end of the corridor, and one beat index per sixth of the journey), the hero carrying no canvas and therefore no 3D object, the fly-through panel genuinely empty for a reader and for a screen reader, every other beat naming where to check its claim and no path into the source anywhere on the page, the character split never breaking a word or nesting a box even under StrictMode, and no anchor leaving this deployment and the cards that arrive after the mount (the template articles the API renders are handed to the reveal observer instead of keeping the hidden state, which is how three invisible cards reached production) the console's live read (a push naming its aggregate refetches, a push naming another one does not, two pushes in a frame cost one read, and a minute of clock passes while the stream is live without a single timed request), the ambient field (a frame really is painted, no 2D context returns nothing rather than throwing, a hidden tab stops drawing, and scrolling genuinely rearranges the structure), 26 measured contrast pairs taken from the stylesheets themselves β both of the workspace's materials, both of the landing page's, the primary-as-type pair, the label on the primary action, and every control boundary β plus the assertions that hold the two stylesheets to one set of brand values and that the surface material re-binds every colour role it borrows (the defect that once put near-black headings on a near-black ground) β axe-core audits of the two public surfaces, the quality queue (the evidence behind each finding is on the row, a decision carries the reason typed with it and re-reads the list it just changed, dismissing is asserted to be a different outcome from resolving, and a second scan is reported as finding nothing new rather than as doing nothing) and the integration screen (the scope catalogue's enforced flag genuinely disables a scope no route declares, a key secret is shown once in a read-only field that carries a panel saying so, rotating a key from its row rewrites the panel in place and its confirmation names the prefix that is about to stop working, a member is offered no rotate control either, an http:// endpoint cannot be added at all, and "send test" reports the receiver's real 500 rather than "sent"), raising a finding by hand from a response row (the raise waits for a reason, and "already in the queue" is asserted to read differently from "recorded"), the definition studio (the JSON panel is the server's own definition; its rows move the XLSForm sheets and the preview with no request at all, because all three read one object; the sheets are the workbook the download writes; the preview hides a question whose relevance rule says so, renders the two inline choices, sends nothing and stores nothing when it is typed into, and produces field-for-field identical markup to the component the collection page renders), the shell guard that keeps the ambient layer and a form field from ever sharing a class name again, the sign-in card's own copy (the mailbox a code arrives from and the lifetime it lives are the server's β GET /auth/mail-status is answered twice with different values and the card is required to state each one, once, so a page with either number typed into it fails the second answer, and a card clicked before that read has landed is required to state the answer that travelled with the code rather than the empty one it was holding), the version history (each row naming the version a version was forked from and not the one above it, the comparison on screen being the sentences that arrived over the wire rather than one the browser derived, a first version saying where the form started instead of showing nothing changed, restore offered only where it means something and reporting both numbers, and the server's own refusal surfaced when it is refused), and the public page's own boundaries: every anchor it renders resolves to this deployment and no source host is named anywhere in it, no em dash or en dash reaches a reader, and the honesty bullets are measured as the brand's dot rather than as a rule that reads like one β plus the brand in the artifacts a stylesheet cannot reach: the README's nine drawings held to the palette by value and to no pre-brand colour or saturated blue-violet accent, every badge colour on this page (their label grounds all the one surface tone, their vendor colours left alone where they belong), and the three app icons decoded pixel by pixel and required to be the palette's own ground, to carry the primary, to be free of blue-dominant pixels and to contain none of the three colours the previous icon was drawn from β the guard that would have caught an icon still wearing the palette it was made in, and the live monitor (the pure model keeps every answerable field, counts a multi-select's respondents rather than its mentions, keeps a missing answer distinct from a wrong one, bins numbers, plots dates and every geo vertex, drops notes and calculations as omitted, and puts every Power BI visual on the 8 px grid; every table exports as CSV and a formula-looking answer is neutralised; the screen mounts over a stubbed wire and proves a push causes a refetch while a minute of clock causes none) |
The eight Fabric CLI tests are separate from the 596: pnpm fabric:test validates the deployment artifact,
the secret boundary, supported-part packaging, the PBIR $schema repair and the semantic-model rewrite
without a tenant. The ten Superset tests are separate too: pnpm superset:test validates the monitor CSV
parser, deterministic slugs and UUIDs, dashboard layout, API payloads, multipart login/CSRF/upload, the
second run updating rather than duplicating, and password redaction. CI runs both sets beside the
workspace suites.
.github/workflows/ci.yml runs on a push to main and on every pull request. verify installs from the
lockfile, starts a real PostgreSQL 16 service, and runs lint, typecheck, the whole suite, the Fabric and
Superset CLI tests, the build and pnpm budgets. harnesses then migrates a database, creates the first owner, starts the API and the web
server, waits for both to answer, and runs both end-to-end harnesses over the same origin a browser uses β
so the route from a build to 35 consumer checks and 28 offline-sync checks is exercised on every push,
with the API and web logs printed when a step fails.
.github/workflows/drift.yml is the other half of that idea, on a clock rather than on a push: CI proves
the code and its migrations agree with each other, against a database the job creates itself, and cannot
see the one database that matters. So every six hours the workflow runs pnpm drift, which reads the
ledger the deployment publishes about itself (GET /api/v1/public/summary) and compares it with the
migration files in the checkout. A disagreement opens β or appends to β a single issue carrying the
report and the command that closes it; a deployment that cannot be read at all fails the run without
an issue, because "we cannot see production" and "production is missing a migration" are different
problems and an alert that conflates them is one people learn to scroll past.
| Check | Fails the build when |
|---|---|
| Critical-path JS / CSS | over 80 kB / 7 kB gzipped, for everything index.html references |
| Landing JS / CSS | over 8 kB / 6 kB gzipped |
| Any single chunk | over 150 kB gzipped |
| The renderer split | a critical chunk contains WebGLRenderer or createInstrument β what a stray static import of the scene would do |
| The trail's chunk | over 4 kB gzipped, absent from the build entirely, or present in a critical chunk (quadraticCurveTo, hsla() β the decoration on every route is the one somebody would import statically "because it is only a canvas" |
| Colour | any of the 26 measured pairs drops below 4.5:1 for text, or 3:1 for a control boundary or the live dot |
| The brand's artifacts | a drawing, a badge or an app icon carries a value from the palette this replaced, or an icon serves a blue-dominant pixel |
| Accessibility | axe-core reports any violation on the landing page or the terms gate |
pnpm coverage # all packages
pnpm --filter @inicio/engine coverage # one package| Package | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
packages/engine |
89.9 % | 81.7 % | 94.3 % | 89.9 % |
apps/api |
84.5 % | 79.5 % | 92.6 % | 84.5 % |
apps/web |
63.6 % (the whole package) Β· 88.6 % (the offline core, --coverage.include='src/offline/**') |
78.5 % | 61.7 % | 63.6 % |
Why the web number is what it is, stated plainly: the components are covered by the jsdom suite
in apps/web/test β the studio, the landing page, the quality queue, the integration screen, the consent
gate, the realtime client, the offline core β and by the two live harnesses below, which drive the real
product through the same origin the browser uses. The package percentage is lower than the API's because
it is measured over the whole package, and the two largest files in it cannot be executed in jsdom at
all: the WebGL scene is a renderer loop (its tiering, teardown and visibility rules are tested against a
stub context) and public/sw.js is a service worker (its routing is executed in a VM). Neither has any
UI to click, so neither is counted as "the app is tested".
Unit tests pass in a mocked world; these two run against a real database and a real deployment,
through the same origin the browser uses (:3000, which proxies /api/v1). Both create a throwaway
organisation and delete it afterwards, leaving the database exactly as they found it.
pnpm e2e:consumer # 35 checks: invitation gate, publish, submissions, exports, enforcement
pnpm e2e:offline # 28 checks: offline contract, conflict retention, PWA, live readsThey need three things: a running stack (pnpm dev:api and pnpm dev:web),
MIGRATION_DATABASE_URL pointing at the owner connection of the database under test β the
harnesses use it only to create and then remove their own throwaway organisation β and E2E_OWNER,
the email of an existing account, which the consumer harness walks the invitation gate as.
E2E_BASE overrides the origin, which is how these same checks were run against the production
deployment.
Verified against both the local stack and the production deployment:
- a device-owned response id, and a retry that resumes instead of duplicating
- two devices colliding on one field: the server's value stored, the device's value read back out of the database, the disagreement recorded
- a doubled submit producing exactly one response
- an irrelevant answer arriving as
null - exports carrying the submitted answer, in every format
- the manifest and the service worker as the browser fetches them, content type included
The one rule that matters. Migrate the database before deploying code that writes new columns or reads new tables. A create path that writes
client_uuidagainst a schema without it fails for every new response. Migration first, then code, then verify.
vercel link # one project: SPA + API, one origin
vercel env add APP_DATABASE_URL # the least-privilege role, NOT the owner
vercel env add MIGRATION_DATABASE_URL
vercel env add OTP_PEPPER
vercel env add APP_ORIGIN # the deployment's own origin β the emails' links are built from it
vercel env add MAIL_FROM # e.g. "Inicio <inicio@unifies.codes>", on a domain verified with the relay
vercel env add RESEND_API_KEY # the relay that carries mail when SMTP ports are refused
# vercel env add MAIL_REPLY_TO # only if that mailbox is read
vercel --prod| Step | Command or check |
|---|---|
| 1 Β· Apply migrations to production | pnpm --filter @inicio/api db:migrate with MIGRATION_DATABASE_URL pointed at production |
| 2 Β· Confirm the schema landed | columns, unique indexes, relforcerowsecurity = true, the policies, the app_role grants |
| 3 Β· Deploy | push to main β the connected project builds and deploys automatically |
| 4 Β· Verify | /health returns a real dbTime; protected routes 401; the SPA renders; static files come back with the right content type |
| 5 Β· Verify the boundary | sign in as two accounts: the second must get 404 on the first's forms, responses, analytics and exports |
https://inicio.unifies.codes is this repository, deployed from main. It is the canonical host;
inicio-prjct.vercel.app and inicio-six-psi.vercel.app are the same deployment's platform hostnames,
which is why no example below carries them. Probed on 2026-10-14, all three returned the same compute
start time to the millisecond and the same database (10 migrations, PostgreSQL 18.6), so they are one
service under three names rather than three deployments. Every line was read from the running service
rather than from a dashboard:
| Read | Answer | What it means |
|---|---|---|
GET /health |
200 {"status":"ok","dbTime":β¦} |
The function reaches the database and runs a real query. The version field beside them is in this checkout and arrives with the next deploy: the row is what the running build answers now, not what the source says |
GET /api/v1/public/summary |
"migrationsApplied": 10, "latestMigration": "0010_api_keys_webhooks_flags.sql", plus a startedAt that is the instance's own compute start and therefore moves on every cold start |
The database this deployment talks to is the one this workspace migrates and the one pnpm drift compares against main. The startedAt is quoted by shape rather than by value on purpose: it is a liveness reading, not a version, and it is what proved on 2026-10-14 that the three hostnames above are one deployment rather than three |
/assets/studio-*.js |
contains "What the respondent opens", "The workbook", "Nothing typed here is sent or stored" | The three-panel studio is deployed and reachable |
/sw.js, /manifest.webmanifest |
application/javascript, application/manifest+json |
The static-asset trap below is genuinely fixed in production, not just locally |
/, /login, /terms, /privacy, /console |
200; no 4xx and no console error on any of them |
The signed-out console is the sign-in sheet in place: /console keeps its URL, draws the email field and "Send me a code", and renders no workspace chrome at all |
/icon-192.png, /icon-512.png, /apple-touch-icon.png |
image/png; ground rgb(25,28,33), primary rgb(234,88,12), 0 blue-dominant pixels |
The tab and home-screen icons are the palette's β they were 92 % old-palette blue pixels until this pass |
/api/v1/api-scopes, /api/v1/flag-kinds |
401 signed out, 200 with a session |
The machine surface denies by default and answers to a session β measured against the deployment after 0010, not before it |
/sitemap.xml, /robots.txt |
application/xml and text/plain, with the four public routes and the private ones closed |
The two files are served as themselves rather than as the app shell, which is what the catch-all below would otherwise do to them |
| The pointer trail, on every route | a canvas.cursor-trail on /, /login, /console, /terms, /privacy, an invitation and a collection link, drawn by the lazily loaded cursor-trail-*.js (1.4 kB gzipped); sized to its own box and painted after a real pointer moves across each one; hidden under prefers-reduced-motion; and not requested at all by a browser that reports no fine pointer |
One mark follows the pointer everywhere. The console used to wear a labelled ring instead and the trail used to stop at the landing page, so a visitor who followed the sign-in link watched the cursor change behaviour halfway through arriving. The last two columns are what a browser pass added: the chunk was being fetched by every device, including the phones that would never draw it, because the gate lived inside the module rather than before the import() |
Production is one database, and this repository points at it: pnpm db:migrate here migrates
production rather than something that resembles it. Read from the connection rather than from a diagram:
| Production β the database the deployment answers from, and the one this workspace uses | |
|---|---|
| Neon project / region | cold-leaf-32411407, ap-southeast-1, the region nearest the people using it |
| Endpoint, database | ep-winter-frost-b3ke8cyz, neondb |
current_setting('server_version') |
18.6 (653b019) |
pg_postmaster_start_time() |
read as 2026-10-14T07:50:59.635Z on this workspace's two connections and in the deployment's own GET /api/v1/public/summary β the same instant from both sides, which is what makes "the deployment dials this database" a reading instead of a guess. It is a reading and not a constant: a free Neon compute suspends after five idle minutes and starts again with a new timestamp, so the check is that both sides agree when read together, not that either equals the number above. pnpm drift is the same trick one level up, comparing the ledger rather than the start time |
schema_migrations |
10, latest 0010_api_keys_webhooks_flags.sql |
| The application's role | app_role, the least-privilege role from migration 0002 β so the API's tenancy refusal is a check that passes rather than one that is skipped |
Migration 0010's objects |
api_key, webhook, webhook_delivery, response_flag β RLS enabled and forced, 2β3 policies each, four functions, the app_role grants |
It used to be two, and that is worth keeping written down. A second copy of the schema lived in this
workspace's own Neon project (proud-field-04065571, us-east-2), and for a while DATABASE_URL here
named that one while the deployment answered from the other β so the two disagreed about the ledger and
neither reading was wrong. They were unified onto the deployment's database, which is the one with the
accounts in it and the one nearest those users; the reasoning and what it costs are in
DECISIONS.md D37, and D36 keeps the record of why the answer was
"measure both and write down which one is behind" for as long as no credential for the other side existed.
The retired project is left in place and read-only, holding one organisation (Unifies) that exists
nowhere else β nothing was deleted, and nothing writes to it again.
Unifying them was one thing; noticing when they come apart again is the other, and that failure hides
well. A migration merged to main either runs against production or it does not, and when it does not the
application is deployed and reachable while the tables its newest screens read do not exist there.
Nothing 500s until somebody opens the new screen, and the health check, the landing page and every older
surface keep answering 200. That is how this deployment once spent a day nine migrations deep while the
repository believed it was migrated β and why the fix was not only to migrate it but to make the gap
somebody's job to notice:
pnpm drift # the deployment's own ledger against this checkout: 0 in step, 1 drift, 2 unreadable
pnpm db:migrate # the migration itself, against the same database, over the owner connectionpnpm drift reads GET /api/v1/public/summary, which reports the schema_migrations ledger the running
service has just read on the connection the application uses: no credentials, no cluster access, nothing
about tenants. .github/workflows/drift.yml runs it every six hours and opens (or appends to) one issue
when it fails. The three exit codes are deliberately distinct, and the distinction is the whole design:
in step, drift, or unreadable. A deployment nobody can reach is not a schema problem, and an alert that
reports it as one is an alert people learn to scroll past.
So the API-key, webhook and quality-flag surfaces β and the Quality and Integrations screens that read
them β are deployed and usable in that deployment, and that was measured rather than argued. Signed in
with a session minted in that database, the console was walked on the deployment: /console at
1440Γ900 and 320Γ568, the sidebar's form, all four of its views (Builder, Responses & exports,
Quality, Distribute), the studio's source panel, every control at least 24px, no sideways scroll at
either width, no console error β 92/92 checks. The two screens 0010 is what makes usable were opened
by name as well: a form's Quality tab renders its live queue ("Scan for duplicates", the speeding and
straight-lining thresholds) off GET /orgs/:orgId/flags β 200, and the sidebar's Integrations view
renders "API keys and webhooks" off GET /orgs/:orgId/api-keys β 200 and GET /orgs/:orgId/webhooks β
200, with the event catalogue (GET /api/v1/webhook-events) β 200. The session those walks used was
minted the way the server mints one, in that database, and deleted afterwards; what it left is the audit
rows a real sign-in also leaves, which is the only trace there should be.
β οΈ The deployment trap this project actually hit
On Vercel, a rewrites entry takes precedence over the static filesystem. A naive SPA catch-all
therefore answered /sw.js and /manifest.webmanifest with index.html and a 200 β so every
"is it up?" check passed while the service worker never installed (HTML is not JavaScript) and the
manifest was never parsed. The offline app worked locally and was silently dead in production.
The fix is to name each root static file in the catch-all's exclusions, and the lesson is in the
verification: check static assets by content type, not by status code. Recorded as D26 in
DECISIONS.md.
sitemap.xml and robots.txt are the same defect in a new dress β a sitemap answered with index.html
and a 200 is a sitemap a crawler reads nothing from β so they were added to that exclusion list in the
same pass that wrote them, and the guard is a test rather than a memory: apps/web/test/sitemap.test.ts
reads vercel.json's negative lookahead and Netlify's force flags, and holds both. It was broken on
purpose once, by removing one name from the list, to confirm it fails.
netlify.toml is written and committed: Netlify serves the SPA and proxies /api/*, /auth/*
and /health to the same origin, so it is a second front door to one backend rather than a second
backend β which matters, because the session cookie is host-only and splitting the two would look
fine until the first login.
netlify deploy --prod --dir apps/web/dist # after `pnpm build`The deploy script is committed at scripts/netlify-deploy.sh; it reads the token from the
environment and needs nothing else. That token is the only thing this step is waiting on.
docker compose -f docker/docker-compose.yml up -d postgres valkey mailpit
pnpm db:migrate && pnpm build
NODE_ENV=production pnpm --filter @inicio/api start # behind docker/Caddyfiledocker/Caddyfile terminates TLS and proxies to the SPA and the API on one hostname, preserving
the same-origin property the cookie depends on.
| Host | Shape | Where the sharp edges are |
|---|---|---|
| Railway / Render / Fly | One Node service plus managed PostgreSQL | Keep NODE_ENV=production and APP_DATABASE_URL on the least-privilege role; run migrations as a release step, not in the start command |
| AWS | ECS Fargate behind an ALB, plus RDS PostgreSQL | The API is stateless and single-process, so one task behind an ALB is enough; RDS needs the extensions enabled and the app role created by the migration |
| DigitalOcean | App Platform plus managed PostgreSQL | Set COOKIE_SECURE=true and APP_ORIGIN to the exact hostname; check that managed Postgres allows CREATE EXTENSION |
| Vercel + Neon (as here) | The verified path | A provider's database integration claims DATABASE_URL for the owner connection β set APP_DATABASE_URL to the app role, or the API will refuse to boot. That refusal is the feature |
Production checklist
-
APP_DATABASE_URLnames a role that is not a superuser and does not haveBYPASSRLS -
MIGRATION_DATABASE_URLis set for the deploy pipeline and nowhere else -
OTP_PEPPERis 32+ random bytes and will not change -
COOKIE_SECURE=true,APP_ORIGIN=https://your.domain,NODE_ENV=production - Migrations applied before the code that needs them
-
/healthreturns a realdbTime;/sw.jsand/manifest.webmanifestreturn their real content types - Two accounts prove the tenant boundary β the second gets
404, not data - A mail relay that is allowed to send to your domain (SMTP or the HTTPS API)
- Backups configured at the database, and one restore rehearsed (this is on the roadmap, not done)
Contributions are welcome β issues, documentation, tests and code. This project has opinions, and they are written down, which makes contributing easier rather than harder.
- Read
specs/PLATFORM.md(the design) and skimDECISIONS.md(the why behind it). A change that contradicts a recorded decision is fine β it just needs an argument good enough to become the next entry. - For anything larger than a bug fix, open an issue first. It is cheaper to disagree in prose than in a diff.
- Reproduce locally:
pnpm install --frozen-lockfile && pnpm dev:api && pnpm dev:web.
| Prefix | Use |
|---|---|
feat/ |
New capability β feat/response-quality-flags |
fix/ |
Bug fix β fix/sw-shadowed-by-spa-rewrite |
docs/ |
Documentation only |
refactor/ |
No behaviour change |
test/ |
Tests only |
chore/ |
Tooling and dependencies |
Keep a branch scoped to one idea. A feat/ branch that also reformats three unrelated files is hard
to review and harder to revert.
- Imperative subject, under ~72 characters. "Refuse a bypassing role on startup", not "Fixed stuff" and not "changes".
- The body explains why, and what it costs. The history in this repository reads like a decision log on purpose: what was wrong, what was tried, what the trade-off is.
- Reference the decision or the issue when one exists (
see D26,closes #42). - Migrations are append-only. Never edit an applied migration; add
0010_β¦. A migration that has run in production cannot be un-run. - Author every commit with your own name and email. The project keeps one attributable history, and no shared or automated identity belongs in it.
- Sign off if you like (
git commit -s); the DCO is welcome, not required.
-
pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm budgetsall pass locally β which is exactly what theverifyjob in.github/workflows/ci.ymlruns, so the same answer arrives either way - New behaviour has a test that fails without your change
-
pnpm coveragedoes not lower the number for the package you touched - A schema change ships as a new migration and a note about its ordering relative to the deploy
- A significant choice is recorded in
DECISIONS.md(one paragraph: context, choice, cost) - Documentation a new user would need is updated β README, roadmap or the spec
- Nothing secret, no
.env, no build output, no editor cruft
Reviewing your own work before a reviewer does
The two live harnesses are the fastest way to find out whether you broke the product end to end:
pnpm e2e:consumer # 35 checks over HTTP against the running stack
pnpm e2e:offline # 28 checks, including the offline sync contractBoth create and delete their own organisation, so running them against your local stack is safe. Do not point them at a production database you care about without reading what they do first.
The full, maintained version β including the honest list of what is not built and the free-tier
ceilings underneath β lives in docs/ROADMAP.md. This is the summary.
- Multi-tenant schema,
FORCErow-level security and an audit trail - Invitation-only identity β one-time codes, sessions, roles (owner / admin / member / viewer)
- Authoring β schema or JSON, with a live analyser that blocks a bad publish
- A real expression engine β relevance, constraints, calculations, choice filters
- XLSForm import and export, with a round-trip fixpoint and a report of what could not be carried
- Word questionnaire intake:
.docxuploads compile through a 12-rule extractor into the canonical model, with statement grids,field-listgroups, SurveyCTO metadata and a report of what needs review - Immutable versions, and a history that says what changed β publish, restore-as-a-new-draft, a version graph keyed by the version each one was forked from, a comparison in plain words computed by the API from the two stored definitions, and a restore that names both numbers it touched
- Distribution β anonymous, single-use, multiple and panel links
- Offline-first collection β cached definition, IndexedDB drafts, durable outbox, autosave
- The answers in your hand β a visible attempt count and next-try time, and a copy of the draft saved to a file and restored with no network at all
- Conflict retention β a disagreement is kept twice and recorded, never silently resolved
- An installable PWA that caches the shell and never the data
- Exports that carry meaning β CSV bundle (data + codebook + labels + missing codes), XLSX, JSON, and SPSS
.sav/ Stata.dtafrom the pyreadstat worker, whose binary output is read back by its own round-trip script β which found and fixed two defects the first time it ran β with&codebook=1shipping the codebook beside the data file - Live analytics recomputed per request, plus per-response conflict counts
- Pushed, not polled: one server-sent event stream per form, coalesced per aggregate, with an honest connection indicator and a timer that only starts when the stream is gone
- An immersive public surface: a WebGL landing page whose every figure is read live from the API, and which is complete and readable without the scene
- Quality flags and a flag queue β speeding, straight-lining, duplicates, and findings raised by hand, each with its evidence and its recorded decision
- API keys with scopes, and webhooks β deny-by-default machine access, HMAC-signed deliveries with retries, and a console screen that shows each secret exactly once
- Consent with evidence, and templates (the 2026 school ranking survey is #1)
- A one-origin deployment with verified tenancy on production
- CI on every push β lint, typecheck, the whole suite against a real PostgreSQL 16, the build and the byte budgets, then both end-to-end harnesses over the same origin a browser uses. Both jobs are green on
main, so "verified" no longer means "verified on somebody's machine at the time" - Component tests for the React UI β the landing page, the realtime client, the ambient field, the console's live read, the quality queue, the integration screen and the definition studio are mounted in jsdom, so the web number is no longer carried by the harnesses alone
- Budgets enforced rather than intended β the gzip ceilings, the assertion that the WebGL scene and the XLSForm writer stay out of the critical chunks, 25 measured colour pairs and an
axe-coreaudit of the public surfaces all fail the build - Attachments, end to end β an
image,audio,videoorfilequestion is authorised, uploaded browser-to-store against a presigned URL, verified against what the store reports, scanned, and only then accepted as an answer; the control says so rather than pretending when a deployment has no bucket -
.sav/.dtaexport β written by the pyreadstat workerPYREADSTAT_WORKER_URLnames, with the same honest501naming that variable when no worker is deployed
- Backups and one rehearsed restore β currently not done, and the single most important gap: nothing dumps the database, and no restore has ever been rehearsed
- A WCAG 2.2 AA audit and a Lighthouse budget β everything a rule engine can decide is already enforced on every push (the colour measurements, the
axe-coreaudit, the byte ceilings); what is still missing is a person with a screen reader, a keyboard-only pass over every signed-in screen, and a Lighthouse run against the deployment with the sign-in flow scripted β which is why Lighthouse is empty rather than filled with a number nobody can reproduce - A visual builder and logic editor β the JSON editor is complete; the drag-and-drop surface is being written now, so treat it as in progress rather than as something you can use yet
- A workspace/project layer between organisation and form
- Paradata capture and export (interview timing, device, edit history)
- Crosstabs and charts inside the console
- CAPTCHA and disposable-domain screening for abuse control
- SSO, passkeys and SCIM
- AI-assisted authoring β draft-only, analyser-validated, off by default, and only with an explicit key
- Internationalisation of the console itself
Why is it invitation only? Surely signup is easier.
Because a survey platform is a place where real people's answers accumulate, and an unauthenticated
signup form is the cheapest way to fill someone's database with noise. An owner or administrator
issues a single-use invitation link; the link stops working the moment it is used. The very first
account cannot be invited by anybody, so it is created deliberately with bootstrap:owner β which is
also the point at which you decide who owns the data.
If you want open signup, set INVITE_ONLY=false. The safe default is the shipped one.
How is this different from a generic form builder?
Four things, and they are the four things that hurt in the field:
- Versions are immutable. Responses stay attached to the version that collected them, so changing a question tomorrow cannot rewrite what yesterday's answers mean.
- It works with no connection, and the app says so honestly instead of showing a spinner.
- A disagreement is data. Two devices editing one field produce two kept values and a conflict row, not a silent last-write-wins.
- The tenant boundary is enforced by PostgreSQL, not by every query remembering a
WHEREclause.
What actually happens when a respondent has no signal for hours?
Every keystroke is written to IndexedDB before anything is attempted over the network, and the draft survives a reload, a crash or a closed tab. The outbox is one deep and durable: a new save supersedes the previous save, and a submit supersedes a pending save, so a long outage cannot build a backlog of stale payloads.
The header tells the truth the whole time: "No connection. Your answers are saved on this device and will be sent automatically", the local save time, the timestamp the server has everything up to, how many times the last send was tried and when the next attempt is due, and a Send now button.
And the answers can leave with you. Save a copy writes the whole draft, queue included, to a JSON file
through the browser's own download β no session, no server, no connection β and Restore a saved copy
reads one back, so a device that is wiped, reinstalled or replaced does not take unsent work with it. A
copy names the survey and the version it came from; one belonging to another link, one written by a newer
app, and one older than the answers already on the device are each refused with a sentence rather than
half-applied. The room the device has left is shown at the top of the form when navigator.storage.estimate()
says it is running out, before it costs anything, and a browser that will not answer is left silent rather
than guessed at.
Two enumerators edit the same answer on two devices. Who wins?
Neither, and that is deliberate. Each sync sends the last server state the device saw (base), so the
server can tell what each side changed:
- only one side changed it β that side wins;
- both changed it the same way β they converge;
- both changed it differently β the server's value is stored, the device's value is preserved in
response_answer.device_value, and aresponse_conflictrow records it.
The export carries the server value, the device value is recoverable from the database, and the Responses tab shows the count per response. Nothing is overwritten and nothing is silently dropped.
Can I actually trust the tenant boundary?
It is tested rather than asserted. Fourteen tenant tables carry ENABLE and FORCE ROW LEVEL SECURITY, the policies read membership rather than trusting a setting, and the test suite forges
app.org_id as the non-superuser app_role and proves zero rows come back. A non-member gets
404 rather than 403 β whether an organisation exists is not a member's business to probe.
On top of that, the API refuses to start in production on a connection that is a superuser or holds
BYPASSRLS, because that is exactly the default a managed database integration hands you.
Is this production ready?
Honest answer: it is verified end to end, and it is not finished. What is proven on the live deployment: invitation-only auth, organisations and roles, authoring and publishing, links, offline collection with conflict retention, exports in three formats, live analytics, and tenant isolation between two accounts.
What is missing before you would run a serious multi-week survey: backups and a rehearsed restore
(the biggest gap), a host for the statistical-export worker if you want .sav/.dta out of a deployment
that has none β the API says exactly that rather than inventing a file β and a manual WCAG 2.2 AA audit
with a screen reader and a keyboard. The rate limiter no longer belongs on that list: it counts in
PostgreSQL (Rate limiting), so a ceiling holds across instances and across the process
recycling a serverless function does between requests, and the migration that creates its counter is
0011, which production has not applied yet (until it does, the limiter fails open and says so). What is
left of the foundation work is in the roadmap with reasons.
Where does my data live, and does anything leave the deployment?
In your PostgreSQL. Outbound traffic is your mail relay (one-time codes and invitation emails) and, for a deployment that points file uploads at object storage, that store β the API presigns those uploads, so no file passes through the serverless function and no store credential reaches a browser. The collection page now uses that path: it asks for a signed URL, the browser sends the bytes to the store, and the API is told when to check what arrived. There is no telemetry, no analytics SDK, no crash reporter, no model provider in the request path, and no AI feature that would ship answers anywhere. Exports are generated by the API from your database on demand.
What happens to .sav and .dta if no export worker is deployed?
They are refused, with a reason, rather than approximated: a wrong SPSS file is worse than an honest
error. A real .sav with value labels, variable labels and missing-code mappings needs pyreadstat,
which is a Python library, so it is written by a worker of its own (services/pyreadstat) rather than
in TypeScript. Point PYREADSTAT_WORKER_URL at one and the export is a real file, with every renamed
variable, truncated name and dropped label reported back; add &codebook=1 and the codebook, value
labels, missing table and metadata travel beside it in one archive, which is where a Stata reader finds
the option codes a .dta cannot carry at all. Deploy nothing and the API answers 501 naming that
variable, and offers CSV, XLSX and JSON instead. The worker is not taken on trust either: its binary
output is read back by services/pyreadstat/test_roundtrip.py and, end to end, by
pnpm --filter @inicio/api roundtrip:statistical β which found two real defects in it the first time it
ran. Shipping a file that opens but means the wrong thing would be the actual bug.
Can I self-host it?
Yes β that is why the API is one Fastify process and the frontend is static files. Compose brings up
PostgreSQL 16, Valkey, MinIO, ClamAV and Mailpit, and docker/Caddyfile terminates TLS in front of
both. The one requirement that is easy to miss: serve the SPA and the API from the same hostname,
because the session cookie is host-only.
What does it cost to run?
This deployment runs on the Vercel and Neon free tiers. Two caveats worth reading before you rely on that:
- Nothing in the product meters or caps responses. Your limits are your hosts', not ours.
- Vercel's Hobby plan is for non-commercial use. If you are deploying this commercially, read the plan's terms and move to a paid tier or another host β the stack is portable, and moving PostgreSQL is one connection string.
Free tiers also mean no backups unless you configure them. Do that before you collect anything you care about.
Why is it called Inicio, and what was "Censo"?
The project began as the portal for a 2026 school ranking survey β that survey is now template #1 rather than the product. "Censo" was the internal working name while the scope was growing; the shipped product is Inicio, and the repository, the deployment and this documentation all use that name.
How do I report a security issue?
Please do not open a public issue. Use GitHub's private vulnerability reporting on this repository (Security β Report a vulnerability), or contact the maintainer through their GitHub profile. Include the smallest reproduction you can, and give a few days before disclosing publicly.
Standing on shoulders, in alphabetical order:
| XLSForm, ODK and KoBoToolbox | The spreadsheet dialect this project imports and exports. The round-trip guarantee exists because their format is a real specification rather than a private guess. |
| PostgreSQL | Row-level security, FORCE RLS and SECURITY DEFINER functions do the heavy lifting for multi-tenancy. The database is a collaborator here, not a bucket. |
| Fastify, Vite, React, TypeScript | The application, the build, the interface and the type safety. |
| Neon and Vercel | The free-tier PostgreSQL and the serverless host running the live deployment. |
| Netlify | The second front door: the SPA mirror that proxies the API to one origin. |
| Resend, nodemailer and Mailpit | The mail transports, including the local inbox that keeps development email off the internet. |
| Valkey, MinIO, ClamAV and Caddy | The self-hosted compose profile: cache and queue, object storage, scanning and TLS. |
Zod, hash-wasm, fflate and pg |
Validation, argon2id and SHA-256 in WASM (so there is no native build step), in-memory zip handling, and the PostgreSQL driver. |
| Vitest, ESLint and pnpm | The test runner, the linter and the workspace manager. |
jsdom, axe-core and yaml |
The test environment that mounts the public surfaces for real, the accessibility audit run against them, and the parser that reads the OpenAPI contract in the test which holds it to the mounted routes. |
| shields.io | The badges in this README. |
And the survey itself: the 2026 school ranking survey this platform grew out of, whose
questionnaire lives in specs/questionnaire.json as the first template β
real fieldwork asks for things a demo never will.
Released under the Apache License 2.0 β see LICENSE.
Copyright 2026 Siba Prasad Panda
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache 2.0 was chosen over MIT for one practical reason: it carries an explicit patent grant, which matters for software an institution might deploy on its own infrastructure. The documentation, the illustrations and the sample questionnaire are covered by the same licence as the code.
If this project is useful to you, the highest-value things you can do are:
- β Star the repository β it is how other people running surveys in the field find it.
- π Report what breaks β a bug report with the request, the expected result and the actual one is worth more than a patch.
- π£ Describe your use case β what your survey looks like decides which roadmap item moves first.
- π Sponsor the work if your organisation depends on it, especially if you need a feature that is on the roadmap and currently nobody's job.
Commercial support, hosting questions, or a feature that needs to exist by a date: open an issue and say so β there is no formal support contract, and the answer will be honest about what is realistic.
Maintaining this section β funding links you may want to add
GitHub Sponsors is linked above. If you also want a coffee link or another platform, add one line here so the README keeps a single, obvious place for funding:
[](https://buymeacoffee.com/<your-handle>)It was left out rather than guessed at: a funding link that points at somebody else's account is worse than no link at all.






