enteros-landing
SEO-first marketing site for Enter OS — the org-native operating system for
AI agent teams. Next.js (App Router, TypeScript), statically prerendered with
output: "standalone" for our container deploy (mirrors the JRS/Minori sites).
- Canonical host:
https://enteros.ai - Routes:
/(home) and/pricing(Platform page, the design's legacy pricing route;/platform308-redirects there)
Design lineage
The DOM, layout, animations, and interactions are a 1:1 port of the internal
design at github.com/EnterOS-AI/Landingpage-Redesign (static "Steep-inspired"
HTML/CSS/JS), componentized into React without changing the rendered markup:
src/app/globals.css— the design'sstyles.css, verbatim except brand tokens and/assets/...URL roots.public/script.js— the design's interaction script (scroll-hero timeline, split-title, Rive demo tabs, reveals), verbatim except absolute asset paths and the split-title word list (fixed to animate the EnterOS headline). Loaded per page bysrc/components/ScriptLoader.tsx; in-site navigation uses plain<a>full page loads so the script re-runs exactly like the static site.src/components/*— one component per section, markup identical to the source.
Brand SSOT
Visual brand comes from @enteros/brand — the Enter OS visual SSOT, in
molecule-ai/molecule-ai-sdk under brand/. It owns the logo, the icon and the
webfonts (Chillax / Inter Tight / JetBrains Mono, declared in src/app/fonts.css,
woff2 under public/assets/fonts/, logo + icon under public/assets/brand/).
Token colors are applied in globals.css (midnight #010120, sky/blush/peach/mint
washes).
The assets are committed, not pulled at build time: @enteros/brand is not
published to our internal npm registry (only @enteros/contracts, @enteros/skill-*
and @molecule-ai/* are), and this site's output: "standalone" Docker build has to
work offline from the tree. scripts/sync-brand.mjs is what puts them here, and what
stops them drifting:
ENTEROS_BRAND_DIR=<path-to>/molecule-ai-sdk/brand npm run brand:sync # copy from the SSOT
ENTEROS_BRAND_DIR=<path-to>/molecule-ai-sdk/brand npm run brand:check # fail if anything differs
brand:check runs in CI (the brand-drift context), so a re-vendored or
hand-edited brand asset is a red build. The script derives its manifest — it parses
the @font-face rules out of both the SSOT's css/fonts.css and this app's
src/app/fonts.css and reconciles them by (family, weight), and takes the logo/icon
filenames from brand.json. Nothing is typed out, so adding a weight to the SSOT is
picked up automatically.
If the package is ever published, sync-brand.mjs already prefers
node_modules/@enteros/brand, so moving to a pinned registry dependency is a
one-line change.
One declared divergence: Chillax ships here in the 400/500/600/700 cut from the
"EnterOS Free Font Pack" (see public/assets/fonts/README.md), while the SSOT ships
300/400/500/600 — differently-subset cuts of the same family, so no weight is
byte-identical. Reconciling them changes rendered type, so it is tracked in #22 and
recorded in DECLARED_DIVERGENCE in scripts/sync-brand.mjs. That list is the only
escape hatch in the drift gate; everything else is hard-gated.
SEO
Metadata API with metadataBase https://enteros.ai, per-page canonical +
OpenGraph/Twitter cards, Organization + WebSite JSON-LD (src/components/JsonLd.tsx),
src/app/sitemap.ts, src/app/robots.ts.
Develop
npm ci
npm run dev # http://localhost:3000
npm run build
npm test # content-migration regression (needs a prior build)
Container
docker build -t enteros-landing .
docker run --rm -p 3000:3000 enteros-landing
Blog content policy (scaling contract)
- Post URLs are flat and permanent:
/blog/<slug>— never section-scoped. Sections and tags are index layers on top; changing post URLs later means mass redirects, so we do not. - Frontmatter is schema-gated (
content-collections.ts):section(IA category) and optionalupdatedDateexist from day one so the corpus never needs a backfill migration. /blog/tag/<tag>(+/page/N) are the crawl/index layers; section routes (/insights, …) go live when their sections have content.- Deferred by design until scale demands: ISR (config change), search (Pagefind on static output).
Contact form
/contact posts to a server action that delivers to team@enteros.ai over
Google Workspace SMTP. The domain already routes MX to Workspace and publishes
Google's DKIM key, so the notification stays inside the tenant and needs no
third-party sender DNS.
| Variable | Required | Purpose |
|---|---|---|
SMTP_USER |
yes | Workspace account that authenticates and sends, e.g. noreply@enteros.ai. |
SMTP_PASSWORD |
yes | 16-character App Password for that account (not its login password). Requires 2-Step Verification on the account, and that the admin has not disabled App Passwords. |
CONTACT_FROM_EMAIL |
no | Full From header override. Only works if the address is a configured "Send mail as" alias of SMTP_USER — Gmail rewrites From to the authenticated account otherwise. |
SMTP_HOST / SMTP_PORT |
no | Default smtp.gmail.com / 587 (STARTTLS). |
Generate the App Password at https://myaccount.google.com/apppasswords while
signed in as SMTP_USER. Gmail caps SMTP sending per user per day (2,000 at
time of writing — check current Google limits); a contact form is nowhere near
it. Unset credentials fail closed: the form tells visitors to email the inbox
directly rather than silently dropping the message.
Nothing is needed at build time — the transport is created on first use, so the
image still builds with no secrets present. The recipient lives in
CONTACT.inbox (src/lib/site.ts), not in an env var, because it is public
information already printed on the page.
Spam handling is a hidden honeypot field plus zod validation. There is no rate limit: the single-container deploy has no shared store to hold counters, so add one at the proxy if the inbox starts taking abuse.
Mail DNS
enteros.ai publishes all four records a receiver looks for. Mail sent as
SMTP_USER through Gmail SMTP passes SPF (Google's IPs, aligned domain) and
DKIM (Google signing the domain), so it passes DMARC on both counts.
| Host | Type | Value |
|---|---|---|
@ |
MX | 1 smtp.google.com |
google._domainkey |
TXT | v=DKIM1; k=rsa; p=… (Workspace-managed) |
@ |
TXT | v=spf1 include:_spf.google.com ~all |
_dmarc |
TXT | v=DMARC1; p=none; rua=mailto:dmarc@enteros.ai; fo=1 |
Two follow-ups on DMARC:
rua=mailto:dmarc@enteros.aionly produces something if that address receives mail. Create it as a user or a group, or reports go nowhere.p=noneasks for reports and changes nothing about delivery. Once the aggregate reports show legitimate mail passing, tighten top=quarantineand thenp=reject. That is the step that actually stops someone spoofing the domain.
Publish exactly one SPF record — a second invalidates both. The apex also
carries a google-site-verification TXT, which is unrelated and fine.
Rendering: ISR (self-hosted)
Every route is incrementally static (revalidate in src/lib/posts.ts,
default 1h). The build seeds a bounded set of pages — newest
PRERENDER_POSTS, first PRERENDER_LIST_PAGES listing pages, busiest
PRERENDER_TAGS tags — and everything else is rendered on first request and
then served from the ISR cache. Below those thresholds this is byte-identical
to a full static build; above them it keeps build time and memory flat as the
corpus grows.
- Cache location:
.next/cacheinside the container. The Dockerfile creates it and declares a volume — mount one to keep the cache warm across restarts (optional; it repopulates on demand). - Replicas: each replica keeps its own cache, which is correct for the
single-container self-host deploy. Running several replicas behind a load
balancer wants a shared
cacheHandlerso they cannot serve different vintages of the same page. - On-demand invalidation:
POST /api/revalidatewithauthorization: Bearer $REVALIDATE_SECRETand{"path":"/blog"}. The route returns 404 unlessREVALIDATE_SECRETis set, so a default deploy exposes no mutation surface. - Note on content freshness: posts are compiled into the bundle at build time, so a revalidation re-renders identical HTML until the image is rebuilt. The win today is lazy generation and a flat build; the revalidate window starts mattering when content or data comes from outside the bundle.