For Engineering

Architecture

The whole stack: one machine, two Cloudflare-fronted hostnames, two systemd services, one git repo, one SQLite DB.

Big picture

                          GitHub (valinor-intra)
                                    │
                                    │ cron: every 5 min
                                    ▼
                         ┌─────────────────────────┐
                         │  ValinorPC (Ubuntu 24)  │
                         │                         │
       ┌─────────────────┤  cloudflared (tunnel)   │
       │ intra.          │           │             │  laurelin.
       │ valinorinfo.com │           │             │  valinorinfo.com
       │                 │           ▼             │
       │      ┌────────────────────────────────┐   │
       │      │       Nginx :80 (local)        │   │
       │      │  static + jsx-wrapped HTML     │   │
       │      └──────────────┬─────────────────┘   │
       │                     │ proxies /api/*      │
       │                     ▼                     │
       │      ┌────────────────────────────────┐   │
       │      │ valinor-intra.service :3000    │◀──┤
       │      │  Node + better-sqlite3         │   │
       │      │  (server.js)                   │   │
       │      └──────────────┬─────────────────┘   │
       │                     │                     │
       │                     ▼                     │
       │      /var/www/team-site/data/             │
       │      laurelin.sqlite (WAL)                │
       │                     ▲                     │
       │                     │                     │
       │      ┌──────────────┴─────────────────┐   │
       │      │ laurelin-sync.service          │   │
       │      │  Node (sync-worker.js)         │   │
       │      │  polls Outlook/Telegram/Notion │   │
       │      └────────────────────────────────┘   │
       │                                           │
       └───────────────────────────────────────────┘
                            ▲
                            │ both URLs gated by
                  Cloudflare Access (@valinordigital.com)
                            ▲
                            │
                          Team

Hosts

ValinorPC

A real machine in the office. No public IP, no router ports open. The only path in is the Cloudflare Tunnel established by cloudflared.

No staging

There's no staging environment. Changes go to main → cron picks them up → they're live within 5 minutes. The mitigation is:

For risky changes (schema rebuilds, route refactors), the convention is "snapshot first, push during quiet hours, watch the deploy log."

The two access paths

Both hostnames share the same Cloudflare Tunnel and the same Access policy (@valinordigital.com). They differ only in what they route to inside the tunnel.

The ingress map in /etc/cloudflared/config.yml is the source of truth for routing. Both hostnames point at http://localhost:3000 (the Node server).

intra.valinorinfo.com — the intranet

Tunnel target: http://localhost:3000 (the Node server, server.js). Node serves the static files and JSX-wrapped HTML out of /var/www/team-site/ and also handles /api/*. There is no separate proxy hop. Node sets Cache-Control: no-cache on .html responses, so a fixed HTML page is not pinned in a browser or at the Cloudflare edge.

Nginx exists on the box (/etc/nginx/sites-available/team-site, web root /var/www/team-site/) and can serve the same files on localhost:80, but the tunnel does NOT route through it. If you are chasing a serving or caching bug, look at Node on port 3000, not Nginx. A 2026-06-22 outage burned hours on the wrong assumption that Nginx was in the path. It was not. See troubleshooting.md.

laurelin.valinorinfo.com — the CRM hostname

Tunnel target: http://localhost:3000 (same Node server). The Laurelin bundle lives at https://laurelin.valinorinfo.com/apps/laurelin.html. Use this hostname when you specifically want the CRM. Most team members bookmark it.

Same files, same backend, same Node process as the intranet hostname. Two hostnames, one room. The split is for convenience: a short URL for the CRM plus a clear label in the address bar.

A second Cloudflare tunnel named valinor-intra (152319d5…) still exists in the account but has no active connections. It is leftover and unused. The live tunnel is laurelin (7a19dd55…).

Same DB, same backend, same Laurelin. Two hostnames, one room. Both gated by the same Cloudflare Access policy.

Why Cloudflare and not Tailscale

Earlier the intranet was Tailscale-gated (http://100.95.70.13 on the tailnet). That required every team member to install and maintain the Tailscale client. Moving to Cloudflare Tunnel + Access means:

If you find any remaining reference to Tailscale, 100.95.70.13, or "tailnet" in the codebase, it's stale — file a doc-fix.

Repo structure

valinor-intra/
├── README.md                  ← top-level (mostly superseded by /docs)
├── CLAUDE.md                  ← agent rules + canonical schema table
├── index.html                 ← intranet homepage
├── server.js                  ← Node entry — http.createServer
├── deploy.sh                  ← runs on ValinorPC via cron
├── package.json
├── api/
├── apps/                      ← JSX apps (auto-wrapped) + laurelin.entry.jsx (esbuild)
├── assets/                    ← brand SVGs
├── data/                      ← seed JSON for fresh deploys
├── deploy/                    ← systemd unit files
├── docs/                      ← these docs (rendered to /var/www/team-site/docs/)
├── laurelin/                  ← Laurelin backend module
│   ├── db.js
│   ├── routes.js
│   ├── seed.js
│   ├── sync-worker.js
│   └── sync/
├── recruiting/                ← Recruiting backend module
├── requests/                  ← intranet request queue (filesystem-based)
├── scripts/                   ← backup-laurelin.sh, generate-*-docs.js, render-docs.js
├── static/                    ← partnership review HTML
└── process-queue.sh           ← processes the request queue

The deploy flow

git push origin main
         │
         ▼
[5-min cron on ValinorPC]
         │
         ▼
git fetch origin main
git diff --quiet HEAD origin/main  ── if true, no-op and exit
         │
         ▼
./deploy.sh
         │
         ├── npm install --production
         ├── copy laurelin/, recruiting/, package.json, node_modules → /var/www/team-site/
         ├── copy index.html, static/*, assets/, apps/rubrics/, apps/library/, apps/proposals/
         ├── esbuild apps/laurelin.entry.jsx → apps/laurelin.js (with content-hash cache bust)
         ├── for each other apps/*.jsx: wrap with React+Babel header/footer → apps/*.html
         ├── generate manifest.json (companies + apps)
         ├── node scripts/generate-schema-docs.js → docs/reference/schema.md
         ├── node scripts/generate-api-docs.js → docs/reference/api.md
         ├── node scripts/render-docs.js /var/www/team-site/docs → HTML + docs-manifest.json
         ├── seed data/*.json → /var/www/team-site/data/ (only if target doesn't exist)
         ├── if backend file newer than service start time:
         │     bash scripts/backup-laurelin.sh pre-deploy-<timestamp>
         │     sudo systemctl restart valinor-intra
         │     verify with systemctl is-active
         ├── install or refresh laurelin-sync.service unit if changed
         └── if sync file newer than service start time:
               sudo systemctl restart laurelin-sync
         │
         ▼
git pull, /var/www/team-site/, services running
         │
         ▼
Team browser refreshes → sees changes (next hard-refresh)

The full script is at the top of the repo: deploy.sh. Reading it once is faster than reading this summary.

JSX wrapping

deploy.sh has two code paths for frontend apps:

Babel-standalone (legacy, used by every app except Laurelin):

For each .jsx in apps/:

  1. Generate an HTML wrapper that loads React 18 + Babel-standalone from a CDN.
  2. Strip the leading import line from the JSX.
  3. Convert export default function Foo()function Foo().
  4. Inline the JSX as <script type="text/babel">…</script>.
  5. Emit apps/<name>.html.

The wrapper provides useState, useEffect, useRef, useCallback, useMemo implicitly (destructured from React in the header). That's the whole import budget — anything else, you write yourself.

esbuild (Laurelin only):

apps/laurelin.entry.jsx is the bundle entrypoint. deploy.sh runs:

./node_modules/.bin/esbuild apps/laurelin.entry.jsx \
  --bundle --minify --target=es2020 --jsx=automatic --loader:.jsx=jsx \
  --outfile=/var/www/team-site/apps/laurelin.js

And emits laurelin.html referencing laurelin.js?v=<contenthash> for cache-busting.

Why the split: Laurelin is large enough that Babel-standalone parsing in the browser was taking ~30s first load. esbuild gets that down to ~3s. Every other app is small enough that Babel is fine.

The systemd units

valinor-intra.service

Runs node server.js. Listens on port 3000 (local-only). Nginx proxies /api/* from either hostname to localhost:3000 — both intra.valinorinfo.com and laurelin.valinorinfo.com tunnel into Nginx, and the static + API split is handled inside Nginx.

Unit file lives at /etc/systemd/system/valinor-intra.service. Restarts on file changes (deploy script handles this with a freshness check, not a watch loop).

deploy.sh requires NOPASSWD sudo for systemctl restart valinor-intra — set in /etc/sudoers.d/valinor-deploy.

laurelin-sync.service

Runs node laurelin/sync-worker.js. Polls Outlook/Telegram/Notion, drives the signature parser and lost-thread detector. Decoupled from the API service so an API restart doesn't lose mid-poll state.

Unit file source: deploy/laurelin-sync.service in the repo. deploy.sh installs/refreshes it.

One-time enable on a new host:

sudo systemctl enable --now laurelin-sync

Database

/var/www/team-site/data/laurelin.sqlite — the one DB. WAL mode. Foreign keys on. Synchronous access via better-sqlite3.

Three files on disk during normal operation:

laurelin.sqlite         ← main DB
laurelin.sqlite-wal     ← write-ahead log
laurelin.sqlite-shm     ← shared memory

Never cp laurelin.sqlite on its own — under write load you get a corrupt snapshot. Use scripts/backup-laurelin.sh, which uses the SQLite .backup command (correct under concurrent writes).

The DB is not in the git repo — it lives only on ValinorPC. Loss of the machine means loss of the DB unless backups exist (they do — see Operations → backups).

State of play summary

Next: Operations for the day-to-day knobs.