Skip to content
flawsomPublic

About

Multi-tenant survey and data-collection platform: a real expression engine, offline-first collection that never loses an answer, and analysis-ready exports. Invitation-only, with tenant isolation enforced by PostgreSQL row-level security.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

Β 

History

54 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Inicio β€” multi-tenant survey and data-collection platform

Inicio

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.


Version License Build CI Tests Typecheck Lint Production

Stars Forks Issues Pull requests Last commit Downloads Contributors

Node pnpm TypeScript React Fastify PostgreSQL


Live demo Documentation Installation Repository


πŸ“– Table of contents

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

What it is

Inicio is three things that are usually three different products:

  1. 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 .docx questionnaire, publish an immutable version, and route a question's visibility, validation and calculation through a real expression engine.
  2. 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.
  3. An export pipeline. CSV bundle (data + codebook + value labels + missing-code table), XLSX, JSON, and SPSS .sav / Stata .dta written 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.

Who it is for

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.

πŸ“΄ Works with no signal

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.

🧩 Never loses an answer

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.

πŸ” Tenancy in the database

Every tenant table has ENABLE and FORCE row-level security. The organisation is a parameter of the connection, so a forgotten WHERE clause cannot leak another tenant's rows.

🧾 Evidence, not promises

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.


✨ Features

🧠 A real expression engine

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.

packages/engine Β· relevance Β· constraint Β· calculation Β· choice_filter

πŸ“΄ Offline-first collection

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.

apps/web/src/offline Β· IndexedDB drafts Β· one-deep durable outbox

πŸ” Idempotent by construction

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 409 already_submitted, which the client reads as success rather than loss.

POST /api/v1/collect/:token/responses

🧩 Conflicts are kept, not resolved away

Every sync sends base β€” the last server state the device saw. Per question: a device-only change wins, a server-only change wins, an identical change converges, and a diverging change stores the server value, retains the device value in device_value, and records device_server_mismatch.

response_conflict Β· per-response conflict counts in the console

πŸ” Multi-tenancy PostgreSQL can defend

Least-privilege app_role, FORCE ROW LEVEL SECURITY on fourteen tenant tables, tenant context set with set_config('app.org_id', …, true) inside the transaction. The API refuses to start in production on a connection that can bypass RLS.

forged-org_id tests Β· BYPASSRLS refusal Β· audit rows

βœ‰οΈ Invitation-only onboarding

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 httpOnly, Secure, SameSite=Lax, with idle and absolute expiry.

/invite/:token Β· /auth/otp/request Β· /auth/otp/verify

πŸ”„ XLSForm round-trip

XLSForm is an adapter, not the model. Importing compiles to the canonical schema, reports what it could not carry, and the guarantee import(export(import(x))) ≑ import(x) is a test rather than a promise. Word questionnaires enter through the same door: a .docx upload is read with a rule-by-rule report, statement grids become field-list groups, and SurveyCTO custom-specify-other metadata survives save, resume and submission as selectedChoices|verbatim.

packages/engine/src/adapters/xlsform.ts Β· packages/engine/src/compiler/docx-compiler.ts

πŸ“€ Exports that mean something

CSV bundle (data, codebook, value labels, missing codes), XLSX and JSON, generated from the definition version that actually collected the data.

GET /forms/:id/responses/export?format=… β€” .sav / .dta are written by the pyreadstat worker (services/pyreadstat) when PYREADSTAT_WORKER_URL names one, and refused with a 501 naming that variable when it does not; &codebook=1 ships the codebook beside the data file, which is where a Stata reader finds the option codes a .dta cannot carry (D9, D30, D42)

πŸ“š Immutable versions, and a history that says what changed

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 ("school" label in hi changed from "ΰ€Έΰ₯ΰ€•ΰ₯‚ΰ€²" to "ΰ€΅ΰ€Ώΰ€¦ΰ₯ΰ€―ΰ€Ύΰ€²ΰ€―".) and a restore that reports both numbers it touched.

POST /forms/:id/versions/:versionId/restore Β· GET …/versions/:versionId/diff Β· packages/engine/src/diff.ts

πŸ“‘ Pushed, not polled

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.

GET /forms/:id/events Β· GET /forms/:id/analytics Β· GET /forms/:id/responses

🧭 Quality you can argue with

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.

response_flag Β· GET /orgs/:orgId/flags Β· PATCH …/flags/:flagId Β· POST …/flags/scan

πŸ”Œ A machine surface that denies by default

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 403 and written to the audit trail as access.denied against the actor that tried, because a denial nobody can reconstruct afterwards is a denial nobody can review.

Authorization: Bearer ik_… Β· POST /orgs/:orgId/api-keys Β· POST …/api-keys/:keyId/rotate Β· POST /orgs/:orgId/webhooks Β· X-Inicio-Signature

πŸ§ͺ One definition, three live views

Open a form and the studio shows the same definition three ways at once: the JSON the API stores, the XLSForm sheets that Download .xlsx writes, and the form a respondent opens from a distribution link. Type a character and all three move, because all three read one parsed object β€” no round trip, no cache, nothing to go stale.

apps/web/src/studio.tsx Β· xlsFormSheets Β· validateForm Β· evaluateForm

🎯 The preview is the form, not a picture of it

The panel on the right is rendered by apps/web/src/respondent.tsx β€” the component the collection page uses β€” over the same definition, evaluated by the same engine the server re-checks a submission with. A question a relevance rule hides is hidden in the preview for the same reason it is hidden on the phone, and a test asserts the two render field-for-field identical markup, so this cannot quietly become a lookalike.

respondent.tsx Β· shared by collect.tsx and studio.tsx

🧩 The workbook on screen is the workbook you download

The middle panel does not re-derive an XLSForm from the definition: it calls xlsFormSheets, the function exportXlsForm zips, and shows those rows with their own headings, sheet by sheet, plus everything the adapter had to normalise. A test holds the panel's rows to the rows inside the written file, so what is on screen and what a spreadsheet opens cannot drift apart.

packages/engine/src/adapters/xlsform.ts Β· asserted in xlsform.test.ts

How the three panels are wired

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  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.

πŸ“Έ Screenshots

Captured, not drawn. The images in this first table are photographs of the running product β€” headless Chromium at 1440Γ—1000 (desktop) and 390Γ—844 (phone) against the live deployment, with nothing staged: the figures on the page are the ones 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.

The landing page's first viewport: three lines of headline type on the dark surface, with no 3D object in front of them

🌐 The landing page β€” the hero is type, not a render
Measured in the capture: 81 % of the frame is the dark surface, and the warm pixels outnumber the cool ones by 59 to 1 Β· 1440Γ—1000

The first beat of the flight: twenty four question slabs risen into an arc, joined by the three kinds of logic filament

🧩 Beat one, the questionnaire as a program
Twenty four slabs in a staggered arc, then the relevance, constraint and calculation filaments drawn between them Β· photographed at 15 % of the flight

The silent fly-through: a corridor of form slabs seen from inside it, with no text on the page

πŸŽ₯ The fly-through, which says nothing
The one beat with no text at all: 0 characters in the active panel, 91 % of the frame is the surface, and the camera banks Β· photographed at 63 % of the flight

Where the flight lands: the light paper section, with the template library set as text rather than as cards

πŸ“„ Where the flight lands: paper
The flight is the dark surface and the reading is white paper, never mixed in one screen (DP-3) Β· 97 % of this frame is the light ground

Sign in with a one-time code on a phone: invitation only, and what the page never reveals

πŸ” Sign-in β€” phone
The same route the landing's button reaches, at 390Γ—844

The landing page on a phone: the same headline, the same six beats, the cheaper scene tier

πŸ“± The landing page β€” phone
A cheaper scene tier under 900 px and nothing else about the page changes Β· 390Γ—844, and a separate pass at 360 px wide found no sideways scroll

Rendered from the real markup β€” the signed-in and collection surfaces, drawn as SVG.

The Inicio console: forms list, and the three-panel studio β€” the JSON definition, the XLSForm sheets a download writes, and the respondent's form as a live preview

πŸ–₯️ Authoring studio β€” desktop
One definition, three live views: JSON, the workbook, and the form a respondent opens

The collection app on a phone with no connection: autosave on, answers saved locally, submit queues

πŸ“± Collection app β€” mobile, offline
Draft on the device, hidden questions, queued submit, connection state

Workspace overview: forms with live response counts, sync conflicts and recent activity

πŸ“Š Workspace overview
Live response counts, sync-conflict totals, recent activity

People and invitations: member roles, single-use invitation links and consent evidence

βš™οΈ People, roles and invitations
Roles enforced in SQL, single-use links, consent evidence

Sign in with a one-time code, captured from the deployment: what a code request answers and what it never reveals

πŸ” Authentication β€” captured from production
One-time codes, invitation gate, no account creation from outside

Responses and exports: live tallies, daily activity, conflict notice and export formats

πŸ“ˆ Analytics and exports
Tallies with counts, daily activity, conflict retention, export formats


One definition read three ways with no server round trip and no cache: xlsFormSheets builds the workbook the download writes, validateForm produces the analyser's findings, and evaluateForm runs the respondent's form in the same component the collection page renders
How the studio is wired. The three panels are not three screens and not three requests: they are one parsed definition read three ways, in the browser, as it is typed. The arrows are function calls from @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
XLSForm import: what the compiler reports, the round-trip guarantee and where the result lands

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.


πŸŽ₯ Demo

Open the live deployment Health check

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
Open the live app, request a code, land in the console.


⏱️ 3 minutes β€” author
Create a form, add a rule, watch the analyser, publish a version.


⏱️ 5 minutes β€” collect
Mint a link, open it on a phone, fill it in, submit.


⏱️ 2 minutes β€” analyse
Watch the tallies move, then export the CSV bundle.

🎬 A recorded tour is not committed yet. The two harnesses in scripts/ are the substitute until one is: they drive sign-in, authoring, publishing, respondent submissions, conflict handling, exports and the PWA through a real database, and print a pass/fail line for every check.

# 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 contract

Want a video here? Drop a file into docs/assets/ and use one of these β€” the section is already structured for it:

<!-- animated walkthrough -->
![60-second tour](./docs/assets/demo.gif)

<!-- committed video, playable in the GitHub viewer -->
https://github.com/flawsom/Inicio/assets/<asset-id>

<!-- or an embed -->
[![Watch the demo](https://img.youtube.com/vi/<video-id>/maxresdefault.jpg)](https://www.youtube.com/watch?v=<video-id>)

πŸ— Architecture

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
Loading
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

How offline collection actually works

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
Loading

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

How multi-tenancy is enforced

-- 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_id returns nothing, because the policies read membership rather than trusting the setting. This is tested, not assumed.
  • A bypassing connection stops the process. On boot the API reads pg_roles for current_user; a superuser or BYPASSRLS role 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.

πŸ›  Tech stack

🎨 Frontend
React Vite TypeScript Service worker IndexedDB
No UI framework and no data-fetching library: one fetch wrapper, hand-written CSS with custom properties, and a service worker that refuses to cache API responses.

βš™οΈ Backend
Fastify Node Zod nodemailer argon2id
TypeScript ESM throughout. WASM hashing rather than a native addon, so the same build runs on any serverless runtime without a compiler.

πŸ—„οΈ Database
PostgreSQL Row level security Migrations
Eleven forward-only SQL migrations. No ORM: typed repository functions, parameterised queries, and RLS underneath all of them.

☁️ Cloud & delivery
Vercel Neon Netlify Docker Caddy

πŸ”Œ APIs & services
REST OpenAPI XLSForm SMTP

πŸ§ͺ Testing & tooling
Vitest ESLint tsx pnpm

πŸ€– 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 docs/ROADMAP.md for the honest list of what is not built.

πŸ’Ύ 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 AWS_ENDPOINT_URL_S3 at MinIO and CLAMD_HOST at ClamAV and uploads, limits and the scan run exactly as they do in production, where the store is a managed one. Nothing in the request path depends on Redis yet, which is why Valkey stays provisioned rather than required.

An honest status beats a stack diagram that lies.


⚑ Quick start

Prerequisites

Requirement Why
Node Node.js 22 or newer The API and tooling target modern ESM and node: builtins
pnpm pnpm 9.15 (corepack enable) Pinned in packageManager; the workspace will not install with npm or yarn
Docker Docker (or any PostgreSQL 16) The compose profile supplies PostgreSQL, Valkey, MinIO, ClamAV and Mailpit
Postgres PostgreSQL 16 with citext and pgcrypto Migrations create the least-privilege role, the extensions and the policies

1 Β· Install

git clone https://github.com/flawsom/Inicio.git
cd Inicio

corepack enable
pnpm install --frozen-lockfile

2 Β· Configure

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 32

Full reference: environment variables.

3 Β· Start the backing services

docker compose -f docker/docker-compose.yml \
  up -d postgres valkey mailpit

Mailpit catches every one-time code locally (its inbox is on http://localhost:8025), so nothing leaves your machine.

4 Β· Migrate, and create the first owner

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

bootstrap:owner is idempotent: re-running it only ensures the membership exists.

5 Β· Run it

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.

6 Β· Prove it works

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 + migrations
Docker 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_URL wins over DATABASE_URL and must name the least-privilege role. A hosting provider's database integration usually claims DATABASE_URL for 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_PEPPER must 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=true and APP_ORIGIN=https://your.domain in 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.

πŸ“ Project structure

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.md is the design. DECISIONS.md is the why β€” including the bugs found in production and the reasoning that closed them. docs/ROADMAP.md is the honest status, including what is deliberately not built.


πŸ” Environment variables

Copy .env.example to .env. It is the authoritative list β€” this table is a map of it.

Runtime

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

Database

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: two names for pooling, one axis that matters for privilege

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.

Authentication and sessions

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

Email

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.

Rate limiting

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.

Attachments and object storage

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.

Cache, bootstrap and access control

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

⚠️ Never commit a real .env. .gitignore covers .env, .env.* and !.env.example β€” and the API logs no secret material, including one-time codes.


πŸ“– API

Base URL

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.

Authentication

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.

Endpoints

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

Example: the console journey

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/..."}

Example: a device that was offline

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 loss

A conflict, observed rather than described. Two devices edit the same field from the same base:

// PATCH #1 - device A moves score 3 -> 20, from base { "score": 3 }
{ "answers": { "score": 20 }, "base": { "score": 3 } }
// -> 200 { "answers": { "score": 20 }, "conflicts": 0 }

// PATCH #2 - device B moves the same field 3 -> 9, from the *same* base
{ "answers": { "score": 9 }, "base": { "score": 3 } }
// -> 200 {
//      "answers":   { "score": 20 },   // the server's value is what the export carries
//      "conflicts": 1                  // and the disagreement is a row, not a log line
//    }
SELECT value, device_value FROM response_answer WHERE response_id = '...';  -- 20 | 9
SELECT kind               FROM response_conflict WHERE response_id = '...';  -- device_server_mismatch

🎯 Usage examples

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

${level} = 'secondary'                        relevance
. >= 0 and . <= 10                            constraint on the current answer
${enrolment} * 2 + 12                         calculation
selected(${facilities}, 'library')            choice filters and selections
not(${attended}) or ${sessions} > 0           boolean composition
import { parseExpression, evaluate } from '@inicio/engine';

const expression = parseExpression('${enrolment} > 0');
const relevant = evaluate(expression, { enrolment: 1248 }); // true

Expressions 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: planSync (apps/api/src/platform/collect.ts) is what decides, per question, whose value wins and which disagreement becomes a conflict row. The browser core decides only what to send and what to keep, which is why the merge rules above are asserted against the API rather than in the queue.

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 null, and the codebook records that it was not asked rather than leaving its absence ambiguous.


πŸ“Š Performance

Measured on the live deployment

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.

⚠️ A sustained burst from one address eventually trips the hosting provider's own bot mitigation (I measured this the hard way). Browsers pass that checkpoint transparently; scripted benchmarks should expect a 403 and back off.

Bundle size

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.

Database

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

Lighthouse

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.


πŸ§ͺ Testing

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

What the 596 tests actually cover

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)

Enforced on every push

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

Coverage

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".

The live harnesses

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 reads

They 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

πŸš€ Deployment

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_uuid against a schema without it fails for every new response. Migration first, then code, then verify.

Vercel β€” verified end to end

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

What the live deployment is right now, measured

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()

One database, and the check that keeps it that way

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.

The check that keeps it honest

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 connection

pnpm 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 β€” prepared, one credential away

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 / self-hosted

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/Caddyfile

docker/Caddyfile terminates TLS and proxies to the SPA and the API on one hostname, preserving the same-origin property the cookie depends on.

Other hosts β€” outlines, not verified claims

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_URL names a role that is not a superuser and does not have BYPASSRLS
  • MIGRATION_DATABASE_URL is set for the deploy pipeline and nowhere else
  • OTP_PEPPER is 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
  • /health returns a real dbTime; /sw.js and /manifest.webmanifest return 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)

🀝 Contributing

Contributions are welcome β€” issues, documentation, tests and code. This project has opinions, and they are written down, which makes contributing easier rather than harder.

Before you start

  1. Read specs/PLATFORM.md (the design) and skim DECISIONS.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.
  2. For anything larger than a bug fix, open an issue first. It is cheaper to disagree in prose than in a diff.
  3. Reproduce locally: pnpm install --frozen-lockfile && pnpm dev:api && pnpm dev:web.

Branch naming

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.

Commit conventions

  • 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.

Pull request process

  • pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm budgets all pass locally β€” which is exactly what the verify job in .github/workflows/ci.yml runs, so the same answer arrives either way
  • New behaviour has a test that fails without your change
  • pnpm coverage does 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 contract

Both 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.


πŸ—Ί Roadmap

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.

βœ… Shipped and verified

  • Multi-tenant schema, FORCE row-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: .docx uploads compile through a 12-rule extractor into the canonical model, with statement grids, field-list groups, 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 .dta from 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=1 shipping 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-core audit of the public surfaces all fail the build
  • Attachments, end to end β€” an image, audio, video or file question 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 / .dta export β€” written by the pyreadstat worker PYREADSTAT_WORKER_URL names, with the same honest 501 naming that variable when no worker is deployed

πŸ”œ Next, in order

  • 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-core audit, 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

🧭 Longer horizon

  • 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

❓ FAQ

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 WHERE clause.
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 a response_conflict row 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.


πŸ™Œ Acknowledgements

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.


πŸ“œ License

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.


πŸ’– Support

Star this repository Sponsor Report an issue

If this project is useful to you, the highest-value things you can do are:

  1. ⭐ Star the repository β€” it is how other people running surveys in the field find it.
  2. πŸ› Report what breaks β€” a bug report with the request, the expected result and the actual one is worth more than a patch.
  3. πŸ“£ Describe your use case β€” what your survey looks like decides which roadmap item moves first.
  4. πŸ’– 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:

[![Buy me a coffee](https://img.shields.io/badge/β˜•_BUY_ME_A_COFFEE-FFDD00?style=for-the-badge&labelColor=191c21&logo=buymeacoffee&logoColor=black)](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.


Inicio β€” real logic, offline collection, analysis-ready data.

Built for the surveys that happen where the signal does not.



Back to top

About

Multi-tenant survey and data-collection platform: a real expression engine, offline-first collection that never loses an answer, and analysis-ready exports. Invitation-only, with tenant isolation enforced by PostgreSQL row-level security.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages