Public
Remixed
Host HTML and Markdown files behind your Val Town org login
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.

Team Artifacts

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.

What you get

  • 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 — .html is served as-is, .md is 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.

Auth: gated to your org, zero config

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.

Other ways to decide who gets in

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.

Creating artifacts

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, field file) — the format is inferred from the filename, so name the field file part notes.md to store Markdown or page.html to store HTML. You can also force it with a kind form field (html or md). Note /upload needs 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, the request_file_upload tool stages a large local .html/.md file out-of-band so you never paste it into context.

How it works

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)
RouteWhoWhat
GET /anyonethe landing page, or the dashboard if you're a member
POST /uploadmemberscreate an artifact from an HTML or Markdown file
GET /a/:idmembersview an artifact (sandboxed)
GET /a/:id/rawmembersthe rendered HTML (Markdown converted), loaded inside the iframe
POST /a/:id/deletemembersdelete an artifact
GET /sourceanyoneredirect 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).

Security model

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 …"> without allow-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 with Content-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 .html file.

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.

Setup

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.

Next steps

  • 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.