diff --git a/README.md b/README.md index 936ae84..15448d5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ REP is an open protocol for injecting environment variables into browser apps **at container runtime** — not build time. It gives you security classification, encryption, integrity verification, and hot reload, with zero build-tool coupling. -[Documentation](https://rep-protocol.dev) +[Documentation](https://rep-protocol.dev) · [AI agent playbook](https://rep-protocol.dev/agents.md) · [llms.txt](https://rep-protocol.dev/llms.txt) ## Quick Start @@ -306,6 +306,19 @@ REP is a formal, open specification — not just a tool. --- +## For AI agents + +The documentation is published in machine-readable form: + +| Resource | What it is | +|---|---| +| [`/agents.md`](https://rep-protocol.dev/agents.md) | Single-page integration playbook — decision tree, install matrix, copy-paste recipes for every framework and deployment target, and the mistakes that break a REP setup | +| [`/llms.txt`](https://rep-protocol.dev/llms.txt) | Index of every documentation page with descriptions | +| [`/llms-full.txt`](https://rep-protocol.dev/llms-full.txt) | The entire documentation set as one plain-text file | +| `.md` | Every page has a plain-text Markdown mirror — e.g. [`/reference/sdk.md`](https://rep-protocol.dev/reference/sdk.md) | + +--- + ## Contributing We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit conventions, and the release process. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 63b5588..76bd991 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,7 +1,10 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import { sidebar } from './src/sidebar.mjs'; +import { markdownHeaders } from './src/integrations/md-headers.mjs'; +import { SITE_URL } from './src/site.mjs'; -const siteUrl = 'https://rep-protocol.dev'; +const siteUrl = SITE_URL; const ogImagePath = '/og-image.png'; const ogImageUrl = `${siteUrl}${ogImagePath}`; const ogImageAlt = @@ -127,122 +130,11 @@ export default defineConfig({ }, }, ], - sidebar: [ - { - label: 'Getting Started', - items: [ - { label: 'Quick Start', slug: 'quick-start' }, - { label: 'Installation', slug: 'guides/installation' }, - { label: 'Local Development', slug: 'guides/development' }, - ], - }, - { - label: 'Core Concepts', - items: [ - { label: 'How REP Works', slug: 'concepts/how-it-works' }, - { - label: 'Variable Classification', - slug: 'concepts/variable-classification', - }, - { label: 'Security Model', slug: 'concepts/security-model' }, - { label: 'Wire Format', slug: 'concepts/wire-format' }, - { label: 'Hot Reload', slug: 'concepts/hot-reload' }, - ], - }, - { - label: 'Framework Guides', - items: [ - { label: 'React', slug: 'frameworks/react' }, - { label: 'Vue', slug: 'frameworks/vue' }, - { label: 'Svelte', slug: 'frameworks/svelte' }, - { label: 'Angular', slug: 'frameworks/angular' }, - { label: 'Vanilla JS', slug: 'frameworks/vanilla' }, - ], - }, - { - label: 'Guides', - items: [ - { label: 'Manifest File', slug: 'guides/manifest' }, - { label: 'Testing', slug: 'guides/testing' }, - { - label: 'Migration', - items: [ - { label: 'Overview', slug: 'guides/migration/overview' }, - { label: 'From Vite', slug: 'guides/migration/from-vite' }, - { - label: 'From Create React App', - slug: 'guides/migration/from-cra', - }, - { label: 'From Next.js', slug: 'guides/migration/from-next' }, - ], - }, - ], - }, - { - label: 'Deployment', - items: [ - { label: 'Docker — Proxy Mode', slug: 'deployment/docker-proxy' }, - { - label: 'Docker — Embedded Mode', - slug: 'deployment/docker-embedded', - }, - { label: 'Kubernetes', slug: 'deployment/kubernetes' }, - { label: 'Docker Compose', slug: 'deployment/docker-compose' }, - { label: 'CI/CD Pipeline', slug: 'deployment/ci-cd' }, - ], - }, - { - label: 'Examples', - items: [ - { label: 'Todo App (React)', slug: 'examples/todo-react' }, - { label: 'Simple HTML (ESM.sh)', slug: 'examples/simple-html' }, - { label: 'Next.js — Proxy Mode', slug: 'examples/nextjs-proxy' }, - { - label: 'Next.js CSR + Kubernetes', - slug: 'examples/nextjs-csr-embedded', - }, - ], - }, - { - label: 'Reference', - items: [ - { label: 'SDK API', slug: 'reference/sdk' }, - { label: 'Gateway Flags', slug: 'reference/gateway-flags' }, - { - label: 'Gateway Endpoints', - slug: 'reference/gateway-endpoints', - }, - { label: 'Manifest Schema', slug: 'reference/manifest-schema' }, - { label: 'CLI Commands', slug: 'reference/cli' }, - { - label: 'Adapter APIs', - items: [ - { label: 'React', slug: 'reference/adapters/react' }, - { label: 'Vue', slug: 'reference/adapters/vue' }, - { label: 'Svelte', slug: 'reference/adapters/svelte' }, - ], - }, - { - label: 'Build-Tool Plugins', - items: [ - { label: 'Vite', slug: 'reference/plugins/vite' }, - { label: 'Next.js', slug: 'reference/plugins/next' }, - ], - }, - { label: 'Codemod', slug: 'reference/codemod' }, - ], - }, - { - label: 'Specification', - items: [ - { label: 'Overview', slug: 'spec' }, - { label: 'REP-RFC-0001', slug: 'spec/rfc-0001' }, - { label: 'Security Model', slug: 'spec/security-model' }, - { label: 'Conformance', slug: 'spec/conformance' }, - ], - }, - { label: 'Contributing', slug: 'contributing' }, - ], + sidebar, + components: { + Head: './src/components/Head.astro', + }, }), + markdownHeaders({ extraPaths: ['/llms.txt', '/llms-full.txt'] }), ], }); diff --git a/docs/public/schema/rep-manifest.schema.json b/docs/public/schema/rep-manifest.schema.json index f279232..4e57203 100644 --- a/docs/public/schema/rep-manifest.schema.json +++ b/docs/public/schema/rep-manifest.schema.json @@ -38,8 +38,8 @@ "default": false }, "default": { - "type": "string", - "description": "Default value if the environment variable is not set. Only valid for non-required variables." + "type": ["string", "number", "boolean"], + "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings." }, "description": { "type": "string", diff --git a/docs/src/components/Head.astro b/docs/src/components/Head.astro new file mode 100644 index 0000000..12be175 --- /dev/null +++ b/docs/src/components/Head.astro @@ -0,0 +1,20 @@ +--- +/** + * Starlight `Head` override. + * + * Advertises the plain-text Markdown mirror of the current page so crawlers and + * AI agents can find it from the HTML without guessing the URL convention. + */ +import Default from '@astrojs/starlight/components/Head.astro'; + +const path = Astro.url.pathname.replace(/\/+$/, ''); +const markdownHref = path === '' ? '/index.md' : `${path}.md`; +const hasMirror = !path.startsWith('/404'); +--- + + +{ + hasMirror && ( + + ) +} diff --git a/docs/src/content/docs/agents.mdx b/docs/src/content/docs/agents.mdx new file mode 100644 index 0000000..53b4713 --- /dev/null +++ b/docs/src/content/docs/agents.mdx @@ -0,0 +1,423 @@ +--- +title: REP for AI Agents — Integration Playbook +description: A single-page, machine-readable playbook for AI coding agents integrating REP. Decision tree, install matrix, copy-paste recipes for SDK, Docker, Kubernetes, and every framework, plus the mistakes that break a REP setup. +--- + +import { Aside } from '@astrojs/starlight/components'; + +This page is written for AI coding agents. It is dense on purpose: everything needed to integrate REP into an application correctly, without reading the rest of the site. + +Every page on this site is also available as plain-text Markdown by appending `.md` to its path — for example [`/reference/sdk.md`](/reference/sdk.md). A machine-readable index of all pages lives at [`/llms.txt`](/llms.txt), and the entire documentation set concatenated into one file at [`/llms-full.txt`](/llms-full.txt). + +## What REP is + +REP (Runtime Environment Protocol) moves frontend environment variables from **build time** to **container runtime**. Instead of `import.meta.env.VITE_API_URL` being replaced with a literal string during `npm run build`, a small Go gateway reads `REP_*` environment variables when the container starts and injects them into every HTML response as an inert `' + +# 3. No secret-shaped literal survived in the bundle +npx @rep-protocol/cli lint --dir ./dist --strict + +# 4. The manifest, if present, is valid +npx @rep-protocol/cli validate +``` + +5. In the browser console, `rep.verify()` returns `true` and `rep.meta()` reports the expected `publicCount`. + +## Source of truth + +When this page is not enough, these are the pages to read next — each is available as `.md`: + +- [Quick Start](/quick-start.md) — the five-minute path +- [How REP Works](/concepts/how-it-works.md) — startup sequence and injection mechanics +- [Variable Classification](/concepts/variable-classification.md) — tier rules and guardrail internals +- [Security Model](/concepts/security-model.md) — threat analyses and hardening +- [Wire Format](/concepts/wire-format.md) — payload JSON, encrypted blob layout, HMAC +- [SDK API](/reference/sdk.md) — full client reference +- [Gateway Flags](/reference/gateway-flags.md) and [Endpoints](/reference/gateway-endpoints.md) +- [REP-RFC-0001](/spec/rfc-0001.md) — the normative specification +- [Conformance](/spec/conformance.md) — what an alternative implementation must satisfy + +Machine-readable indexes: [`/llms.txt`](/llms.txt) · [`/llms-full.txt`](/llms-full.txt) · [payload schema](/schema/rep-payload.schema.json) · [manifest schema](/schema/rep-manifest.schema.json) diff --git a/docs/src/content/docs/deployment/kubernetes.mdx b/docs/src/content/docs/deployment/kubernetes.mdx index c330782..d95768b 100644 --- a/docs/src/content/docs/deployment/kubernetes.mdx +++ b/docs/src/content/docs/deployment/kubernetes.mdx @@ -95,14 +95,10 @@ spec: - "--upstream=localhost:80" - "--port=8080" - "--strict" + - "--env-file=/config/.env" - "--hot-reload" - "--hot-reload-mode=file_watch" - "--watch-path=/config" - envFrom: - - configMapRef: - name: frontend-public-config - - secretRef: - name: frontend-sensitive-config volumeMounts: - name: config-volume mountPath: /config @@ -130,16 +126,37 @@ spec: name: frontend-dynamic-config ``` +The mounted ConfigMap must hold a single key containing a whole `.env` file, because `--env-file` reads one env-format file: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend-dynamic-config +data: + .env: | + REP_PUBLIC_API_URL=https://api.example.com + REP_PUBLIC_FEATURE_FLAGS=dark-mode + REP_PUBLIC_ENV_NAME=production +``` + ## Hot reload with ConfigMaps Use file watch mode to detect ConfigMap changes without pod restarts: -1. Mount the ConfigMap as a volume -2. Enable `--hot-reload --hot-reload-mode=file_watch --watch-path=/config` -3. Update the ConfigMap: `kubectl apply -f configmap.yaml` -4. Kubernetes refreshes the mounted volume -5. The gateway detects the change and broadcasts via SSE -6. Connected browsers receive the update +1. Mount the ConfigMap as a volume, with a single key holding a whole `.env` file +2. Point the gateway at it with `--env-file=/config/.env` — this is what reload re-reads +3. Enable `--hot-reload --hot-reload-mode=file_watch --watch-path=/config` +4. Update the ConfigMap: `kubectl apply -f configmap.yaml` +5. Kubernetes swaps the volume's `..data` symlink, changing the mount directory's mtime +6. The gateway detects the change, re-reads the env file, and broadcasts via SSE +7. Connected browsers receive the update + + + +Variables passed through `envFrom` alone are **not** hot-reloadable. Changing `frontend-public-config` or `frontend-sensitive-config` above requires a pod restart (`kubectl rollout restart`). To rotate a `REP_SENSITIVE_*` value without one, move it into the mounted env file — sourced from a Secret instead of a ConfigMap — since `--env-file` accepts a single path. ```bash # Update a config value diff --git a/docs/src/integrations/md-headers.mjs b/docs/src/integrations/md-headers.mjs new file mode 100644 index 0000000..c808a2d --- /dev/null +++ b/docs/src/integrations/md-headers.mjs @@ -0,0 +1,56 @@ +import { readdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +/** Cloudflare Pages allows at most 100 header rules. */ +const RULE_LIMIT = 100; + +const MARKDOWN_HEADERS = [ + 'Content-Type: text/plain; charset=utf-8', + 'Access-Control-Allow-Origin: *', + 'Cache-Control: public, max-age=600, must-revalidate', +]; + +const PREAMBLE = `# Generated at build time by src/integrations/md-headers.mjs — do not edit. +# +# Cloudflare Pages serves .md as text/markdown, which browsers download instead +# of rendering. The Markdown mirrors exist to be read — by agents and by people — +# so they are served as text/plain with CORS open. +# +# Rules are enumerated per file rather than globbed: Pages splat patterns match +# greedily and a "/*.md" suffix pattern is not documented as supported. +`; + +/** + * Emits `dist/_headers` covering every generated Markdown mirror. + */ +export function markdownHeaders({ extraPaths = [] } = {}) { + return { + name: 'rep:md-headers', + hooks: { + 'astro:build:done': async ({ dir, logger }) => { + const root = fileURLToPath(dir); + const entries = await readdir(root, { recursive: true }); + + const markdownPaths = entries + .filter((entry) => entry.endsWith('.md')) + .map((entry) => `/${entry.split(path.sep).join('/')}`) + .sort(); + + const paths = [...markdownPaths, ...extraPaths]; + if (paths.length + 1 > RULE_LIMIT) { + logger.warn( + `${paths.length + 1} header rules exceeds the Cloudflare Pages limit of ${RULE_LIMIT}. ` + + 'Markdown mirrors beyond the limit will be served as text/markdown instead of text/plain.', + ); + } + + const rules = paths.map((p) => [p, ...MARKDOWN_HEADERS.map((h) => ` ${h}`)].join('\n')); + rules.push(['/schema/*', ' Access-Control-Allow-Origin: *'].join('\n')); + + await writeFile(path.join(root, '_headers'), `${PREAMBLE}\n${rules.join('\n\n')}\n`, 'utf8'); + logger.info(`Wrote _headers for ${paths.length} Markdown mirror(s).`); + }, + }, + }; +} diff --git a/docs/src/lib/to-markdown.ts b/docs/src/lib/to-markdown.ts new file mode 100644 index 0000000..46c6ce0 --- /dev/null +++ b/docs/src/lib/to-markdown.ts @@ -0,0 +1,171 @@ +/** + * MDX → plain Markdown. + * + * The docs are authored in MDX with Starlight components. Agents ingesting the + * `.md` mirrors should not have to parse JSX, so component wrappers are + * unwrapped into their closest Markdown equivalent and `import` statements are + * dropped. + * + * Everything inside a fenced code block is passed through untouched — several + * pages document JSX and would otherwise be mangled. + */ + +const FENCE = /^(\s*)(`{3,}|~{3,})/; +const IMPORT_LINE = /^import\s.+\sfrom\s+['"][^'"]+['"];?\s*$/; +const IMPORT_OPEN = /^import\s*\{[^}]*$/; +const IMPORT_CLOSE = /\}\s*from\s+['"][^'"]+['"];?\s*$/; + +/** Read a `foo="bar"` attribute out of a JSX opening tag. */ +function attr(tag: string, name: string): string | undefined { + const match = tag.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)'|\\{'([^']*)'\\})`)); + if (!match) return undefined; + return match[1] ?? match[2] ?? match[3]; +} + +const ASIDE_LABEL: Record = { + note: 'Note', + tip: 'Tip', + caution: 'Caution', + danger: 'Danger', +}; + +/** Strip the common leading indent from a block of lines. */ +function dedent(lines: string[]): string[] { + const widths = lines + .filter((line) => line.trim() !== '') + .map((line) => line.match(/^[ \t]*/)![0].length); + if (widths.length === 0) return lines; + const min = Math.min(...widths); + return lines.map((line) => line.slice(min)); +} + +/** + * A component body being buffered until its closing tag. + * + * Bodies are indented relative to their wrapper in the source. Once the wrapper + * is gone that indentation is meaningless — and at four spaces it would parse + * as a code block — so each body is dedented and re-emitted at the indent the + * opening tag sat at. + */ +type Block = { + tag: 'Aside' | 'Card' | 'TabItem'; + indent: string; + lines: string[]; +}; + +export function mdxToMarkdown(source: string): string { + const lines = source.split('\n'); + const out: string[] = []; + + let block: Block | null = null; + let fence: string | null = null; + + const emit = (line: string) => (block ? block.lines : out).push(line); + + const flush = () => { + if (!block) return; + const { tag, indent } = block; + const body = dedent(block.lines); + for (const line of body) { + if (tag === 'Aside') out.push(line === '' ? `${indent}>` : `${indent}> ${line}`); + else out.push(line === '' ? '' : `${indent}${line}`); + } + block = null; + out.push(''); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // --- fenced code: verbatim passthrough -------------------------------- + const fenceMatch = line.match(FENCE); + if (fence) { + emit(line); + if (fenceMatch && fenceMatch[2][0] === fence[0] && fenceMatch[2].length >= fence.length) { + fence = null; + } + continue; + } + if (fenceMatch) { + fence = fenceMatch[2]; + emit(line); + continue; + } + + const trimmed = line.trim(); + const indent = line.slice(0, line.length - line.trimStart().length); + + // --- ESM imports ------------------------------------------------------- + if (IMPORT_LINE.test(trimmed)) continue; + if (IMPORT_OPEN.test(trimmed)) { + while (i < lines.length && !IMPORT_CLOSE.test(lines[i].trim())) i++; + continue; + } + + // ---