Skip to content

Repository files navigation

KiwiHacks Beacons

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.

How it fits together

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.

Production readiness

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/live and authenticated /internal/health/db both 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.

NocoDB setup

Create a base containing the following tables. Field names are case-sensitive to the API.

programs

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

attendees

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.

Configuration

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.

Verify before deploying

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.

Deploy with Docker Compose or Portainer

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 ps

For 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:

  1. protect /admin* with an Access application restricted to organisers;
  2. prevent public traffic from bypassing the tunnel and reaching the VPS origin;
  3. preserve Origin and Referer headers for admin CSRF checks;
  4. rate-limit /api/webhooks/*, /api/public/*, and /admin/*; and
  5. 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.

Health checks and operations

Container liveness is available without authentication:

GET /health/live

Database readiness requires the independent health secret:

curl --fail --silent --show-error \
  --header "Authorization: Bearer YOUR_HEALTHCHECK_SECRET" \
  https://YOUR-BACKEND/internal/health/db

Expected 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_failed
  • leaderboard_refresh_failed
  • loops_email_failed
  • server_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.

Safe CSV imports

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

If preflight passes, stop the Beacons container so the importer becomes the only database writer, then run:

npm run import:csv -- path/to/attendees.csv --commit

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

Organiser workflow

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

Public leaderboard integration

Nova or another allowed browser origin can request:

GET https://YOUR-BACKEND/api/public/programs/:program-slug/leaderboard

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

Production checklist

Hard launch gates:

  • The NocoDB schema matches the tables above, optional defaults are unset, and active defaults to true.
  • 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 201 for a new signup and 200 for a duplicate.
  • A referral code from one program is ignored in another.
  • The public response contains only displayName and referralCount.
  • Liveness and authenticated database-readiness monitoring are active.
  • NocoDB/PostgreSQL backups and a restore test are complete.
  • Loops delivery and the manual loops_email_failed recovery 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.

Security notes

  • 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 /admin through 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/.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages