Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@cloudhal/docs",
"version": "0.0.1",
"private": true,
"description": "cloudhal.dev: the developer site (docs, JSON Schemas, the fetcher page). ADR-0037.",
"type": "module",
"scripts": {
"build": "node scripts/build.ts",
"deploy": "node scripts/build.ts && wrangler deploy"
},
"devDependencies": {
"wrangler": "catalog:"
}
}
89 changes: 89 additions & 0 deletions apps/docs/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Builds cloudhal.dev into `dist/` (ADR-0037):
*
* - `static/` as is (home page, /fetch, 404, `_headers`, `_redirects`, robots.txt);
* - `/schemas/v1/<name>.schema.json` from `packages/core-contracts/schemas`, the permanent `$id`
* URLs, plus `/schemas/v1/index.json` listing them;
* - `/llms.txt`, an index for agents (llmstxt.org);
* - one `/docs/guides/<name>` redirect per guide, prepended to `_redirects` (static rules win over
* the `/docs/*` catch-all).
*
* Every schema's `$id` must already be its URL here; the build fails otherwise.
*/
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const APP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCHEMAS = path.resolve(APP, "../../packages/core-contracts/schemas");
const GUIDES = path.resolve(APP, "../../docs/guides");
const DIST = path.join(APP, "dist");
const ORIGIN = "https://cloudhal.dev";

interface SchemaEntry {
readonly name: string;
readonly url: string;
readonly title: string;
}

async function copySchemas(): Promise<SchemaEntry[]> {
const out = path.join(DIST, "schemas/v1");
await mkdir(out, { recursive: true });
const entries: SchemaEntry[] = [];
for (const file of (await readdir(SCHEMAS)).filter((f) => f.endsWith(".schema.json")).sort()) {
const text = await readFile(path.join(SCHEMAS, file), "utf8");
const schema = JSON.parse(text) as { $id?: string; title?: string };
const url = `${ORIGIN}/schemas/v1/${file}`;
if (schema.$id !== url) throw new Error(`${file}: $id is ${schema.$id}, expected ${url}`);
await writeFile(path.join(out, file), text);
entries.push({ name: file, url, title: schema.title ?? file });
}
await writeFile(
path.join(out, "index.json"),
`${JSON.stringify({ version: 1, schemas: entries }, null, 2)}\n`,
);
return entries;
}

function llmsTxt(schemas: readonly SchemaEntry[]): string {
return [
"# Cloudhal",
"",
"> Agent-native site platform: sites are typed graphs, agents change them through an MCP server behind a policy engine, and every change is a snapshot you can diff, preview and roll back. Runs on Cloudflare or self-hosted. Open source, TypeScript.",
"",
"## Docs",
"",
"- [README](https://raw.githubusercontent.com/cloudhal-dev/cloudhal/main/README.md): install, first site, connect an agent",
"- [Guides](https://github.com/cloudhal-dev/cloudhal/tree/main/docs/guides): deploy button, search, schedules, comments, multi-site, translations",
"- [Architecture decisions](https://github.com/cloudhal-dev/cloudhal/tree/main/docs/adr)",
"- [AGENTS.md](https://raw.githubusercontent.com/cloudhal-dev/cloudhal/main/AGENTS.md): conventions for coding agents working on Cloudhal",
"",
"## JSON Schemas (v1)",
"",
...schemas.map((s) => `- [${s.title}](${s.url})`),
"",
"## Optional",
"",
"- [Fetcher](https://cloudhal.dev/fetch): what the Cloudhal-Fetch/1 user agent requests",
"- [Playground](https://try.cloudhal.com): a private Cloudhal site for one hour, no account",
"",
].join("\n");
}

await rm(DIST, { recursive: true, force: true });
await cp(path.join(APP, "static"), DIST, { recursive: true });
const schemas = await copySchemas();
await writeFile(path.join(DIST, "llms.txt"), llmsTxt(schemas));
const guides = (await readdir(GUIDES)).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3));
const redirects = path.join(DIST, "_redirects");
await writeFile(
redirects,
guides
.map(
(g) =>
`/docs/guides/${g} https://github.com/cloudhal-dev/cloudhal/blob/main/docs/guides/${g}.md 302`,
)
.join("\n")
.concat("\n", await readFile(redirects, "utf8")),
);
console.log(`built cloudhal.dev: ${schemas.length} schemas`);
16 changes: 16 additions & 0 deletions apps/docs/static/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found</title>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<main>
<header><a class="brand" href="/">Cloudhal</a></header>
<h1>Not found</h1>
<p>There is nothing at this address. Try the <a href="/">home page</a>, the <a href="/docs">docs</a> or the <a href="/schemas/v1/index.json">JSON Schemas</a>.</p>
</main>
</body>
</html>
15 changes: 15 additions & 0 deletions apps/docs/static/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains

/schemas/*.schema.json
Content-Type: application/schema+json; charset=utf-8

/schemas/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=3600

/llms.txt
Content-Type: text/markdown; charset=utf-8
Access-Control-Allow-Origin: *
5 changes: 5 additions & 0 deletions apps/docs/static/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Until the docs site lands, /docs points at the docs on GitHub (302: these links will change).
/docs/deploy https://github.com/cloudhal-dev/cloudhal/blob/main/docs/guides/deploy-button.md 302
/docs https://github.com/cloudhal-dev/cloudhal#readme 302
/docs/* https://github.com/cloudhal-dev/cloudhal/tree/main/docs 302
/github https://github.com/cloudhal-dev/cloudhal 302
41 changes: 41 additions & 0 deletions apps/docs/static/fetch.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloudhal-Fetch</title>
<meta name="description" content="What the Cloudhal-Fetch/1 user agent requests from your server, and how to reach the people running it.">
<link rel="stylesheet" href="/site.css">
</head>
<body>
<main>
<header>
<a class="brand" href="/">Cloudhal</a>
<nav><a href="/docs">Docs</a><a href="https://github.com/cloudhal-dev/cloudhal">GitHub</a></nav>
</header>

<h1>Cloudhal-Fetch</h1>
<p>You found <code>Cloudhal-Fetch/1 (+https://cloudhal.dev/fetch)</code> in your logs. It is the media fetcher of <a href="/">Cloudhal</a>, an open-source site platform.</p>

<h2>Who sends it</h2>
<p>Cloudhal is self-hosted: every site runs on its owner's own Cloudflare account or server. A request with this user agent comes from <strong>one such site</strong>, when its owner or an agent they approved imports an image or file by URL. The Cloudhal project does not crawl the web and does not run these requests itself.</p>

<h2>What it requests</h2>
<ul>
<li>A single <code>GET</code> for the exact URL someone gave it. It does not follow links or crawl.</li>
<li>No cookies, no <code>Authorization</code> header, no headers forwarded from the person's browser.</li>
<li>HTTPS on port 443 only; at most 3 redirects, each checked again.</li>
<li>Media types only (images, video, audio, documents). Web pages are refused.</li>
<li>At most 25 MB, within 30 seconds.</li>
</ul>

<h2>Blocking it</h2>
<p>Because each request comes from a different site's infrastructure, block by user agent: refuse requests whose <code>User-Agent</code> starts with <code>Cloudhal-Fetch/</code>. The fetcher treats any status other than 200 as a failure and tells the person the import did not work.</p>

<h2>Contact</h2>
<p>If a Cloudhal site is misusing the fetcher, write to <a href="mailto:[email protected]">[email protected]</a> with the request time, URL and source address. The rules above are specified in <a href="https://github.com/cloudhal-dev/cloudhal/blob/main/docs/rfcs/0004-assets-and-media.md">RFC-0004 §5.7</a>.</p>

<footer><a href="/">cloudhal.dev</a></footer>
</main>
</body>
</html>
46 changes: 46 additions & 0 deletions apps/docs/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloudhal for developers</title>
<meta name="description" content="Cloudhal is an open-source, agent-native site platform. Install it, read the docs, use the JSON Schemas.">
<link rel="stylesheet" href="/site.css">
<link rel="alternate" type="text/markdown" href="/llms.txt" title="llms.txt">
</head>
<body>
<main>
<header>
<a class="brand" href="/">Cloudhal</a>
<nav>
<a href="/docs">Docs</a>
<a href="https://github.com/cloudhal-dev/cloudhal">GitHub</a>
<a href="https://www.npmjs.com/package/@cloudhal/cli">npm</a>
<a href="https://try.cloudhal.com">Playground</a>
</nav>
</header>

<h1>Sites your agents can build, safely.</h1>
<p>Cloudhal is an open-source site platform. Sites are typed graphs; agents change them through an MCP server behind a policy engine; every change is a snapshot you can diff, preview and roll back. It runs on Cloudflare or on your own machine.</p>

<h2>Start</h2>
<pre><code>npm create cloudhal@latest my-site
cd my-site
npx cloudhal dev</code></pre>
<p>Then connect your agent: <code>claude mcp add cloudhal -- npx cloudhal mcp --mode edits</code>. Any MCP client works.</p>
<p>Or deploy a blog to your Cloudflare account in one click with the <a href="https://deploy.workers.cloudflare.com/?url=https://github.com/cloudhal-dev/cloudhal-template">Deploy to Cloudflare button</a>, or try a private site for an hour at <a href="https://try.cloudhal.com">try.cloudhal.com</a>.</p>

<h2>Reference</h2>
<ul>
<li><a href="/docs">Docs</a>: guides and architecture decisions (on GitHub until the docs site is ready).</li>
<li><a href="/schemas/v1/index.json">JSON Schemas v1</a>: the contracts every Cloudhal implementation shares. Their URLs are permanent.</li>
<li><a href="/llms.txt">llms.txt</a>: an index of this site for agents.</li>
<li><a href="/fetch">Cloudhal-Fetch</a>: what the fetcher requests from your server, and how to reach us.</li>
</ul>

<footer>
Cloudhal is open source under Apache-2.0. Product site: <a href="https://cloudhal.com">cloudhal.com</a>. Security: <a href="mailto:[email protected]">[email protected]</a>.
</footer>
</main>
</body>
</html>
2 changes: 2 additions & 0 deletions apps/docs/static/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
112 changes: 112 additions & 0 deletions apps/docs/static/site.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
:root {
--bg: #fbfbfa;
--fg: #1c1c1a;
--muted: #5f5f5a;
--line: #e4e4e0;
--accent: #b4441f;
--code: #f1f1ee;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141413;
--fg: #ececea;
--muted: #a3a39d;
--line: #2c2c2a;
--accent: #ee8a64;
--code: #1f1f1d;
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font:
16px / 1.6 ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
}
main {
max-width: 44rem;
margin: 0 auto;
padding: 4rem 1rem 5rem;
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.brand {
font-weight: 600;
font-size: 1.125rem;
color: var(--fg);
text-decoration: none;
}
nav {
display: flex;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.9375rem;
}
h1 {
font-size: 2rem;
line-height: 1.2;
font-weight: 600;
margin: 3rem 0 0.75rem;
}
h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 2.5rem 0 0.5rem;
}
p,
li {
color: var(--muted);
}
strong {
color: var(--fg);
font-weight: 600;
}
a {
color: var(--accent);
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.875em;
}
pre {
background: var(--code);
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.75rem 1rem;
overflow-x: auto;
color: var(--fg);
}
code {
background: var(--code);
border-radius: 4px;
padding: 0.1em 0.3em;
}
pre code {
background: none;
padding: 0;
}
ul {
padding-inline-start: 1.25rem;
}
footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-size: 0.875rem;
color: var(--muted);
}
15 changes: 15 additions & 0 deletions apps/docs/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// cloudhal.dev (ADR-0037): static assets only, no Worker code.
// Until the Starlight site lands (RFC pending), /docs redirects to the docs on GitHub.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "cloudhal-dev",
"compatibility_date": "2026-10-01",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
"html_handling": "drop-trailing-slash"
},
"routes": [{ "pattern": "cloudhal.dev", "custom_domain": true }],
"workers_dev": false,
"preview_urls": false
}
4 changes: 2 additions & 2 deletions docs/adr/0037-domain-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- Sources: [Cloudflare: create a self-managed OAuth client](https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/) · [HSTS preload list (all `.dev` names are preloaded)](https://hstspreload.org) · [EmDash playground](https://github.com/emdash-cms/emdash/tree/main/demos/playground)

## Context
Cloudhal owns exactly two domains, `cloudhal.com` and `cloudhal.dev`, and will not buy a third. The repo already uses `https://cloudhal.dev/schemas/v1/*` as JSON Schema `$id`, `https://cloudhal.dev/docs`, the bot User-Agent page `https://cloudhal.dev/fetch`, and `security@`, `legal@` and `[email protected]` for mail. As of 2026-10-20 neither domain has DNS records.
Cloudhal owns exactly two domains, `cloudhal.com` and `cloudhal.dev`, and will not buy a third. The repo already uses `https://cloudhal.dev/schemas/v1/*` as JSON Schema `$id`, `https://cloudhal.dev/docs`, the bot User-Agent page `https://cloudhal.dev/fetch`, and `security@`, `legal@` and `[email protected]` for mail. As of 2026-10-20 neither domain had DNS records; cloudhal.dev went live on 2026-10-21 from `apps/docs`.

Planned: a future hosted control plane that installs and manages Cloudhal in the customer's own Cloudflare account, and a public playground modelled on EmDash's (a per-visitor SQLite Durable Object, one-hour TTL, no login) in a separate repository, `cloudhal-dev/try`. The question is which domain carries what, and where customer sites live.

Expand Down Expand Up @@ -49,6 +49,6 @@ Planned: a future hosted control plane that installs and manages Cloudhal in the

## Operator checklist (not done by this change; done in Cloudflare)
- [ ] DNS for both zones.
- [ ] Serve `packages/core-contracts/schemas` at `https://cloudhal.dev/schemas/v1/` with `Content-Type: application/schema+json`.
- [x] Serve `packages/core-contracts/schemas` at `https://cloudhal.dev/schemas/v1/` with `Content-Type: application/schema+json`.
- [ ] SPF, DKIM and DMARC for `cloudhal.com`.
- [ ] `cloudhal.dev` sends no mail: `v=spf1 -all`, DMARC `p=reject`, null MX.
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading