Beacons is the private backend for KiwiHacks program signups, referral codes, and public leaderboards. Fillout sends authenticated signups to the service, the service stores them in NocoDB, and Loops can email each accepted attendee their referral code. Organisers create programs and rotate webhook keys through a Cloudflare Access-protected dashboard.
The service is deliberately small: it runs on Node.js 22, has no npm runtime dependencies, and ships as a locked-down Docker container.
Fillout ── authenticated webhook ──▶ Beacons ── server token ──▶ NocoDB
│
Nova/public site ◀── leaderboard JSON ───┤
├──▶ Loops email
Organisers ── Cloudflare Access ────────▶ /admin
Monitor ── independent bearer token ───▶ /internal/health/db
Only Beacons should write to the two NocoDB tables. Database credentials, Loops credentials, and webhook-key hashes remain server-side. Public leaderboard responses contain only a display name and referral count.
This repository is ready for a limited, small-event production deployment once the hard launch gates below are complete. Its integrity model is intentionally server-side rather than database-enforced.
Hard launch gates:
- run exactly one Node.js process/container;
- do not configure multiple replicas or Node cluster workers;
- do not create or edit program and attendee records directly in NocoDB;
- route every mutation through this service; and
- protect
/admin*with Cloudflare Access and prevent direct access to the VPS origin; - store real secrets outside Git and use a base-scoped NocoDB token;
- confirm
/health/liveand authenticated/internal/health/dbboth succeed; and - complete a signup, duplicate-signup, cross-program referral, and backup/restore smoke test.
Within one process, signups for the same normalized email and program are serialized before the NocoDB insert. The service also validates names, email addresses, referral formats, program scope, and webhook credentials.
Known limitations accepted by this deployment profile:
- database
NOT NULL, composite uniqueness, checks, and foreign keys are not relied upon; - the readiness endpoint verifies schema shape and connectivity, not indexes or relational constraints;
- Loops email delivery has no durable retry queue; and
- the application trusts Cloudflare Access for admin authentication and must not have a bypassable origin.
If the service is ever horizontally scaled or another writer is introduced, first add PostgreSQL composite unique indexes on:
CREATE UNIQUE INDEX IF NOT EXISTS attendees_program_email_uidx
ON attendees (program_slug, email_normalized);
CREATE UNIQUE INDEX IF NOT EXISTS attendees_program_owned_code_uidx
ON attendees (program_slug, owned_referral_code);Database foreign keys and format checks are also worthwhile defence in depth, but they are not required for the documented single-writer deployment.
Create a base containing the following tables. Field names are case-sensitive to the API.
| Field | NocoDB type | Application requirement | Default and rules |
|---|---|---|---|
Id |
ID | Yes | NocoDB primary key |
name |
Single line text | Yes | Maximum 120 characters |
public_slug |
Single line text | Yes | Enable Unique values only |
webhook_secret_hash |
Single line text | Yes | Written by the service |
loops_transactional_id |
Single line text | No | Leave the default blank |
active |
Checkbox | Yes | Default true |
| Field | NocoDB type | Application requirement | Default and rules |
|---|---|---|---|
Id |
ID | Yes | NocoDB primary key |
program_slug |
Single line text | Yes | Program scope |
first_name |
Single line text | Yes | Maximum 100 characters |
last_name |
Single line text | Yes | Maximum 100 characters |
preferred_name |
Single line text | No | Leave the default blank |
email |
Email or single line text | Yes | Stored lowercase |
email_normalized |
Single line text | Yes | Duplicate-check value |
owned_referral_code |
Single line text | Yes | Generated by the service |
referral_code_used |
Single line text | No | Leave the default blank |
In NocoDB, a blank default means “no default”; optional values sent by the service are stored as database NULL. Do not enter the literal text NULL as a default. Do not mark email_normalized or owned_referral_code individually unique: their intended uniqueness is scoped to one program.
Marking the required fields as Not Null and enabling Unique values only for public_slug are recommended defence in depth. They are not a substitute for the composite indexes required before multiple writers or replicas are allowed.
Use a dedicated NocoDB API token restricted to this base. The service needs table metadata plus record read/write access.
Copy .env.example to .env for local Compose deployment, or add the same values as protected stack variables in Portainer. Never commit .env.
| Variable | Required | Description |
|---|---|---|
NOCODB_URL |
Yes | NocoDB HTTP(S) origin |
NOCODB_API_TOKEN |
Yes | Dedicated server-side API token |
NOCODB_PROJECT_ID |
Yes | Base ID containing both tables |
HEALTHCHECK_SECRET |
Yes | Independent random secret of at least 32 characters |
PUBLIC_BACKEND_URL |
Yes | Canonical public origin; HTTPS is required in production |
ADMIN_ORIGINS |
No | Comma-separated origins allowed to submit admin forms; defaults to the backend origin |
PUBLIC_SITE_ORIGINS |
No | Comma-separated browser origins allowed to call the leaderboard API |
LOOPS_API_KEY |
No | Required only when a program has a Loops transactional ID |
LEADERBOARD_CACHE_TTL_MS |
No | Cache duration from 1–300 seconds; default 30000 |
ADMIN_TITLE |
No | Private dashboard heading |
DEBUG_LOGS |
No | Secret-free diagnostic events; normally false |
PORT |
No | Listening port; default 3000 |
Generate secrets with a cryptographically secure password manager or secret generator. Startup fails when required variables are missing, a URL is invalid, the health secret is too short, or production is configured without HTTPS.
Node.js 22 is required when running outside Docker.
npm test
npm run check
docker compose --env-file .env.example config --quiet
docker build --tag beacons:local .The GitHub Actions workflow runs these checks on every push and pull request.
The included Compose service builds the application, runs it as the unprivileged node user, drops Linux capabilities, uses a read-only filesystem, rotates container logs, and binds the host port only on 127.0.0.1.
For Docker Compose:
cp .env.example .env
# Fill in .env with production values.
docker compose config --quiet
docker compose up --detach --build
docker compose psFor Portainer, deploy the repository as a Git-backed stack and enter the .env.example keys in the stack environment editor. Do not paste secrets into the Compose file or repository. Keep the replica count at one, deploy a reviewed commit or release tag, and disable uncontrolled automatic updates.
Route the hostname to http://127.0.0.1:3000 through a host reverse proxy or an appropriately configured Cloudflare Tunnel. If the tunnel runs in another container, give it an explicit route to the host-bound service rather than publishing Beacons on all interfaces.
Configure Cloudflare to:
- protect
/admin*with an Access application restricted to organisers; - prevent public traffic from bypassing the tunnel and reaching the VPS origin;
- preserve
OriginandRefererheaders for admin CSRF checks; - rate-limit
/api/webhooks/*,/api/public/*, and/admin/*; and - restrict
/internal/*to the monitoring path that needs it.
After each deployment, confirm the container is healthy, run the authenticated database-readiness request below, and complete one non-production test signup before directing event traffic to the service. Review docker compose logs --tail=100 beacons without enabling debug logs.
For an application rollback, redeploy the previous reviewed commit or release tag and repeat the health checks. Application rollback does not reverse database records; recover data only through the tested NocoDB/PostgreSQL backup procedure.
Container liveness is available without authentication:
GET /health/liveDatabase readiness requires the independent health secret:
curl --fail --silent --show-error \
--header "Authorization: Bearer YOUR_HEALTHCHECK_SECRET" \
https://YOUR-BACKEND/internal/health/dbExpected response:
{"ok":true}The readiness check confirms that both tables have the expected columns and can be read. It does not validate PostgreSQL indexes or constraints.
Back up the NocoDB/PostgreSQL data regularly and test restoration. Alert on readiness failures and these structured log events:
request_failedleaderboard_refresh_failedloops_email_failedserver_shutdown_failed
Loops delivery happens after the attendee is saved. A Loops failure does not cause Fillout to retry the signup, and there is no durable email outbox, so loops_email_failed needs an organiser recovery process.
The tracked importer accepts Fillout-style CSV exports without logging attendee names, emails, or referral codes. It performs a read-only preflight by default, validates every row before writing, detects duplicate emails and referral codes, and orders rows so an imported referrer exists before a referred attendee.
Required CSV headers are First Name (legal), Last Name (legal), and Email Address. Optional headers are Preferred Name, Referral Code, and Owned Referral Code; unrelated export columns are ignored.
Run the preflight from a secured workstation. The command loads .env when present, while already-exported environment variables take precedence:
npm run import:csv -- path/to/attendees.csvIf preflight passes, stop the Beacons container so the importer becomes the only database writer, then run:
npm run import:csv -- path/to/attendees.csv --commitThe write requires an exact interactive confirmation. It is sequential, does not send Loops emails, and stops on the first database failure. A retry is safe: attendees already written are skipped by normalized email. CSV files remain ignored by Git and should be deleted securely after the import and backup window.
The importer is intentionally excluded from the production container. Run it from a secured operator workstation, and restart Beacons only after the import reports completion.
Open /admin through Cloudflare Access. Create a program with an optional Loops Transactional ID and immediately save the generated webhook key; only its SHA-256 hash is stored, so the original key cannot be recovered. Rotating the key invalidates the previous key without changing the program URLs.
Configure Fillout to send:
POST https://YOUR-BACKEND/api/webhooks/fillout/bp_PROGRAM_IDENTIFIER
Authorization: Bearer bk_PROGRAM_KEY
Content-Type: application/json{
"firstName": "Alice",
"lastName": "Example",
"preferredName": "Ali",
"email": "alice@example.com",
"referralCodeUsed": "MIA-80A1C7DD2F10"
}firstName, lastName, and email are required. preferredName and referralCodeUsed may be empty. Referral codes accept ASCII letters, numbers, _, and - and are normalized to uppercase.
Webhook outcomes:
| Status | Meaning |
|---|---|
201 |
Signup created |
200 |
Duplicate normalized email ignored within this program |
400 |
Invalid JSON or field value |
401 |
Unknown/inactive program or incorrect webhook key |
415 |
Incorrect content type |
503 |
Temporary NocoDB failure; the webhook may be retried |
Nova or another allowed browser origin can request:
GET https://YOUR-BACKEND/api/public/programs/:program-slug/leaderboardNo authorization header is required. Add the frontend origin to PUBLIC_SITE_ORIGINS so the browser receives the correct CORS header.
const response = await fetch(
"https://YOUR-BACKEND/api/public/programs/bp_PROGRAM_IDENTIFIER/leaderboard",
);
if (!response.ok) throw new Error("Leaderboard unavailable");
const leaderboard = await response.json();Example response:
[
{ "displayName": "Ali", "referralCount": 3 },
{ "displayName": "Mia", "referralCount": 1 }
]Only attendees with at least one valid same-program referral appear. The response never includes email addresses, raw referral codes, program secrets, or database IDs. Results are cached for LEADERBOARD_CACHE_TTL_MS and refreshed after an accepted signup.
Hard launch gates:
- The NocoDB schema matches the tables above, optional defaults are unset, and
activedefaults totrue. - Beacons is the only writer and exactly one Node.js process/container is running.
- Production secrets live outside Git and the NocoDB token is base-scoped.
- Tests, syntax checks, Compose validation, and the image build pass in CI.
- Cloudflare Access protects
/admin*and the VPS origin cannot bypass it. - Fillout returns
201for a new signup and200for a duplicate. - A referral code from one program is ignored in another.
- The public response contains only
displayNameandreferralCount. - Liveness and authenticated database-readiness monitoring are active.
- NocoDB/PostgreSQL backups and a restore test are complete.
- Loops delivery and the manual
loops_email_failedrecovery path have been tested.
Recommended defence in depth for the single-writer deployment:
- Mark application-required NocoDB fields Not Null.
- Enable Unique values only for
programs.public_slug. - Apply database checks and foreign keys when direct PostgreSQL administration is available.
Required before scaling or introducing another writer:
- Apply both composite unique indexes shown above.
- Confirm the indexes and relational constraints through PostgreSQL metadata queries.
- Program URLs use random 144-bit identifiers; webhook keys use independent 256-bit secrets.
- Only webhook-key hashes are stored.
- Logs intentionally omit names, emails, program identifiers, credentials, upstream response bodies, and NocoDB filter paths.
- Admin authentication is delegated to Cloudflare Access; do not expose
/adminthrough an unprotected origin. - CSV exports, database dumps, diagnostic scratch scripts, and local handoff notes are intentionally ignored by Git. The reviewed importer is tracked separately under
tools/.