Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

lightweightindustries

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.


The website

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.

Other routes

RouteWhat it does
/the home page
/<slug>the page in content/<slug>.md
/sourceredirects to this val's code
/auth/resetclears the unlock cookie (see below)
/api/unlockreceives the email form
/auth/google/callbackreceives Google's sign-in POST
/mcpthe MCP server (see below)

Gates

A gate trades a visitor's identity for something you don't give away for free. The site ships with one: your booking calendar.

How it works

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 -->
KeyWhere it showsIf you leave it out
promptthe line above the Google buttona plain generic sentence
buttonthe email form's submit button"Continue"
fallbackthe summary on the collapsed email form"Use your email instead"
assurancea small line under the formnothing 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.

What happens in between

  • The Google path (the default) opens Google's own account chooser in a popup. It returns a verified address along with the visitor's given name, family name, and company domain.
  • The email form (the fallback) captures whatever they type. It is not verified.

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.

Adding a second gate

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.

Two deliberate decisions worth knowing

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.


The MCP server

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.

ToolWhat it does
list_contentLists the page slugs and gate names the site is made of
read_contentReturns 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.

Protecting it

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.

Connecting a client

{ "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.


Environment variables

None of these are required for the site to serve pages. Each one missing degrades a feature rather than breaking the site.

VariablePowersIf it is missing
GOOGLE_CLIENT_IDthe Continue with Google buttonthe button renders disabled and visibly names the variable, rather than vanishing — a missing button reads as a bug
NOTION_API_KEYrecording registrations in Notionthe gate still unlocks; the address is logged, not recorded
REGISTRATIONS_DATABASE_IDwhich Notion database receives themsame as above
MCP_TOKENthe /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.

The registrations database

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:

PropertyTypeNotes
Nametitledisplay name from Google, or the address when there isn't one
Emailemail
Given name, Family name, DomaintextGoogle path only; blank for typed submissions
Gateselectwhich gate they opened
Sourceselectgoogle or typedthe column that says how much to trust the address

Created time is Notion's own, and it is the event timestamp.


Architecture

Three layers, no shortcuts between them:

request → route → controller → service → outside world
  • Routes (backend/routes/) extract what the request carries and return a response. No decisions.
  • Controllers (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.
  • Services (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