The public website at lightweightindustries.com, plus the backend behind it.
Content is authored as markdown and rendered to HTML on the server. There is no client-side framework and no build step — a request comes in, a markdown file is read, and finished HTML goes out.
A page is a markdown file. content/home.md is the home page.
The block at the top of the file, between the --- lines, is the frontmatter. It sets the
browser tab title and the description search engines show:
---
title: Lightweight Industries
description: Small, sharp software for people who'd rather own their tools.
---
Everything below the frontmatter is the page body, written as ordinary markdown.
Adding a page is adding a file. Dropping in content/about.md serves it at /about with
no code change. An unknown slug returns a real 404.
Pages are server-rendered on purpose rather than assembled in the browser: a marketing site needs to be crawlable by search engines and to paint immediately, and neither is true of a JavaScript app that fetches its own content after loading.
| Route | What it does |
|---|---|
/ | the home page |
/<slug> | the page in content/<slug>.md |
/source | redirects to this val's code |
/auth/reset | clears the unlock cookie (see below) |
/api/unlock | receives the email form |
/auth/google/callback | receives Google's sign-in POST |
/mcp | the MCP server (see below) |
A gate trades a visitor's identity for something you don't give away for free. The site ships with one: your booking calendar.
A gate is a content file plus a token. Its frontmatter holds every word the gate says while it is locked; its body is what a visitor gets once it opens:
---
prompt: Let me know who's asking and I'll show you my calendar.
button: Show me the calendar
fallback: Use your email instead
---
<!-- whatever the visitor gets once they've identified themselves -->
| Key | Where it shows | If you leave it out |
|---|---|---|
prompt | the line above the Google button | a plain generic sentence |
button | the email form's submit button | "Continue" |
fallback | the summary on the collapsed email form | "Use your email instead" |
assurance | a small line under the form | nothing at all — the line simply doesn't exist |
No visitor-facing copy lives in the code. That is deliberate: a second gate offering
something other than a calendar writes its own words instead of inheriting these. assurance
having no default is part of the same idea — a gate should be able to say less, not just
say something different.
A page opts in by putting the matching token on a line of its own:
## Availability
{{gate:calendar}}
The server replaces that token with one of two things.
If the visitor hasn't identified themselves, they get the prompt, a Continue with Google button, and a collapsed disclosure hiding a plain email form.
If they have, they get a line reading "Continuing as name@example.com · (Not you?)" and then the reward. The identity line comes first on purpose — it says whose view this is before handing over what was unlocked, rather than trailing after a 600px embed.
Either way the address is written to a Notion database, and a cookie records that this visitor has already paid the toll so they are never asked twice.
Create content/gates/<name>.md and put {{gate:<name>}} on its own line in a page.
That's the whole procedure — adding a gate is never a code change.
The unlock cookie is unsigned. Forging it reveals a calendar link that is handed to anyone who signs in, so signing the cookie would buy nothing. It is not a security boundary. If a gate is ever put in front of something genuinely valuable, that decision has to be revisited.
The cookie lasts a year and is httpOnly, which means that without an escape hatch
nobody — the author included — could ever see their own gate again after opening it once.
That is what /auth/reset and the "not you?" link are for.
An MCP server lets an AI assistant call specific functions
you have defined, instead of poking at the site blindly. This one is mounted at /mcp and
speaks streamable HTTP.
| Tool | What it does |
|---|---|
list_content | Lists the page slugs and gate names the site is made of |
read_content | Returns one page or gate as raw markdown, frontmatter intact |
Both are read-only.
It is built on @modelcontextprotocol/server@2 — the v2 TypeScript SDK, compatible with the
2026-07-28 MCP spec and still serving older clients — which is the shape Val Town's own
templates/mcp-server uses.
Most Val Town MCP servers are protected by making the whole val private, letting the platform
edge turn away anyone without a bypass token. That is not available here, because this val
serves a public website. So /mcp checks a secret of its own:
Authorization: Bearer <MCP_TOKEN>
It fails closed — if MCP_TOKEN is not set, every request is refused rather than allowed
through. While the val's HTTP privacy is still restricted, a client needs the platform's
X-Val-Town-Access header as well; once the val is public, the bearer token is the only
thing standing in front of the tools.
{ "mcpServers": { "lightweightindustries": { "type": "http", "url": "https://lightweightindustries.val.run/mcp", "headers": { "Authorization": "Bearer <MCP_TOKEN>" } } } }
MCP clients cache the tool list per session, so a newly added or renamed tool only appears after the client reconnects. Tool behavior is always live.
None of these are required for the site to serve pages. Each one missing degrades a feature rather than breaking the site.
| Variable | Powers | If it is missing |
|---|---|---|
GOOGLE_CLIENT_ID | the Continue with Google button | the button renders disabled and visibly names the variable, rather than vanishing — a missing button reads as a bug |
NOTION_API_KEY | recording registrations in Notion | the gate still unlocks; the address is logged, not recorded |
REGISTRATIONS_DATABASE_ID | which Notion database receives them | same as above |
MCP_TOKEN | the /mcp guard | /mcp refuses every request |
There is no Google client secret. The sign-in flow is Google Identity Services, which happens in the browser and has no code exchange, so no secret exists to store.
Every unlock writes one row. It is an append-only event log — nothing is ever read back or updated, so the same person registering twice produces two rows. That is intended: events are the lossless record, and a deduplicated view of people is a separate, derived thing best built later against real data. Keeping this write-only is also what stops an unverified typed submission from ever mutating a row that Google verified.
The Notion integration must be shared with the database in Notion's own UI. The schema the code writes:
| Property | Type | Notes |
|---|---|---|
Name | title | display name from Google, or the address when there isn't one |
Email | ||
Given name, Family name, Domain | text | Google path only; blank for typed submissions |
Gate | select | which gate they opened |
Source | select | google or typed — the column that says how much to trust the address |
Created time is Notion's own, and it is the event timestamp.
Three layers, no shortcuts between them:
request → route → controller → service → outside world
backend/routes/) extract what the request carries and return a response. No
decisions.backend/controllers/) validate, decide, and orchestrate. They return a
plain result object, never an HTTP response — which is exactly what lets the MCP tools call
the same functions the web pages use, with no route in front.backend/services/) talk to the outside world — files, Notion, Google. They
never throw; every failure comes back as a value.backend/utils/ holds pure backend helpers, and shared/ holds code safe for both the server
and a browser.
content.service.ts is the seam. Content is files today, but every read goes through that
one service — so moving content to Notion or a CMS later changes that file and nothing else.
backend/
index.http.ts entry — mounts the routes
routes/ pages, api, auth, mcp
controllers/ page (renders), content (reads source), gate
services/ content, notion, google.oauth, blob
utils/ frontmatter, markdown, layout, html, gate, slug
content/
home.md a page
gates/calendar.md a gate
shared/ types + the result helper, browser-safe