Host HTML pages and Markdown docs behind your Val Town org login. Drag an .html or .md file onto the dashboard and get a link only your teammates can open — no passwords, no per-user setup.
Live demo: https://templates--95d06ed2675811f18f781607ee4eb77e.web.val.run — the landing page is public; the artifacts behind it are not.
Click Remix to get your own copy. It works the moment it's yours.
- A public landing page that explains what the space is and who can get in.
- A members-only dashboard that lists every artifact and creates one by drag-and-drop.
- HTML and Markdown support —
.htmlis served as-is,.mdis rendered to a clean, readable page. - Each artifact rendered in a sandboxed iframe, so its scripts can't act as the viewer.
- Example artifacts to start (including a Markdown demo) — delete them whenever.
This uses std/oauth ("Log in with Val Town"). The trick that makes it a one-liner: oauthMiddleware derives the org from whoever owns the val and hands you session.isOrgMember. Remix this into your org and it gates to your team automatically — there's nothing to configure.
import { getOAuthUserData, oauthMiddleware } from "https://esm.town/v/std/oauth/middleware.ts";
const requireOrg = async (c, next) => {
const session = await getOAuthUserData(c.req.raw);
if (!session?.user) return c.redirect("/auth/login"); // not logged in
if (!session.isOrgMember) return c.text("Org members only", 403); // wrong team
await next();
};
export default oauthMiddleware(app.fetch); // adds /auth/login, /auth/callback, /auth/logout, /auth/logo
Remix into a personal account instead of an org and isOrgMember is true only for you — a private space of one.
isOrgMember is the easy default, but getOAuthUserData returns the whole user, so you can gate however you want. Replace the check in requireOrg:
By email domain — anyone with a company address:
const email = session.user.email ?? "";
if (!email.endsWith("@yourcompany.com")) return c.text("Forbidden", 403);
By an explicit allowlist — a hand-picked set of usernames:
const ALLOWED = new Set(["alice", "bob"]);
if (!session.user.username || !ALLOWED.has(session.user.username)) {
return c.text("Forbidden", 403);
}
By Val Town user id — the most stable key (ids never change and are never null):
const ALLOWED_IDS = new Set(["<uuid-1>", "<uuid-2>"]);
if (!ALLOWED_IDS.has(session.user.id)) return c.text("Forbidden", 403);
You can mix them too — org members plus a couple of outside collaborators by email.
Drag an .html or .md file onto the dashboard (or click to pick one). It's stored and gets a short URL like /a/3fa9c21b1e. The format is detected from the file extension: .html/.htm is served as-is, while .md/.markdown is converted to a styled HTML page when viewed. Each artifact records its kind ("html" or "md") in the database, shown as a small badge on the dashboard.
🤖 For LLMs / agents: create an artifact by POSTing to
/upload(multipart, fieldfile) — the format is inferred from the filename, so name the fieldfilepartnotes.mdto store Markdown orpage.htmlto store HTML. You can also force it with akindform field (htmlormd). Note/uploadneeds a logged-in org-member session, so an anonymous POST just redirects to login. If you're an assistant editing this val over the Val Town MCP, therequest_file_uploadtool stages a large local.html/.mdfile out-of-band so you never paste it into context.
main.tsx Hono routes + the org auth gate (the http entry)
backend/
db.ts SQLite: store / list / fetch / delete artifacts (+ kind)
render.ts turn an artifact into HTML (Markdown → HTML; HTML as-is)
frontend/
Layout.tsx HTML shell (Twind for styling)
Landing.tsx logged-out marketing page
Dashboard.tsx members: list + drag-and-drop uploader
Viewer.tsx sandboxed iframe that renders one artifact
AccessDenied.tsx logged in, but not on the team
components.tsx shared login / logout buttons
upload.client.ts drag-and-drop browser script
examples/ seed artifacts (real .html and .md files)
| Route | Who | What |
|---|---|---|
GET / | anyone | the landing page, or the dashboard if you're a member |
POST /upload | members | create an artifact from an HTML or Markdown file |
GET /a/:id | members | view an artifact (sandboxed) |
GET /a/:id/raw | members | the rendered HTML (Markdown converted), loaded inside the iframe |
POST /a/:id/delete | members | delete an artifact |
GET /source | anyone | redirect to this val's source code |
GET /auth/* | — | added automatically by oauthMiddleware |
Artifacts live in this val's own SQLite database, so every remix starts isolated (with just the seed examples).
Artifacts are arbitrary content your team uploads, so they're treated as untrusted — including Markdown, which is converted to HTML and can itself embed raw HTML and <script> tags:
- They render in
<iframe sandbox="allow-scripts …">withoutallow-same-origin. The artifact gets an opaque origin — its JavaScript runs, but it can't read your cookies or call this app's routes as you. /a/:id/raw(the rendered artifact HTML) is gated to members and served withContent-Security-Policy: sandbox, so even opening it directly gives it an opaque origin — same protection as the iframe, no matter how it's reached.- Markdown is converted to HTML server-side (
backend/render.ts) but flows through the exact same sandbox, so it's no more privileged than an uploaded.htmlfile.
The trust boundary is your team: anyone who can log in can also upload. Don't open it to people you wouldn't give a shared drive.
Nothing required — std/oauth is zero-config. Optionally set OAUTH_STATE_ENCRYPTION_KEY (generate one at https://generate-random-signing-key.val.run) for faster cold starts.
- Restyle the landing page in
frontend/Landing.tsx. - Add tags or search in
backend/db.ts. - Swap the org gate for one of the allowlist patterns above.