diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts
index bbe6f47..0947db3 100644
--- a/src/lib/nostr.ts
+++ b/src/lib/nostr.ts
@@ -7,6 +7,7 @@ import {
} from "applesauce-core/helpers/pointers";
import { RelayPool } from "applesauce-relay";
import { catchError, firstValueFrom, of, timeout, toArray } from "rxjs";
+import { createBlogPostCache } from "./blog-post-cache";
const { getTagValue } = Helpers;
@@ -102,7 +103,7 @@ export async function fetchBlogPosts(): Promise
{
console.log("[nostr] Starting fetchBlogPosts...");
await ensureWebSocket();
console.log("[nostr] WebSocket ensured");
-
+
const relayPool = getPool();
const store = getEventStore();
@@ -200,42 +201,23 @@ export async function fetchBlogPostByDTag(dTag: string): Promise();
-const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+const blogPostCache = createBlogPostCache({
+ fetchBlogPostByDTag,
+ fetchBlogPosts,
+});
/**
* Fetch blog posts with caching
*/
export async function fetchBlogPostsCached(): Promise {
- const cacheKey = "blog_posts";
- const cached = cache.get(cacheKey);
-
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
- return cached.data;
- }
-
- const posts = await fetchBlogPosts();
- cache.set(cacheKey, { data: posts, timestamp: Date.now() });
-
- return posts;
+ return blogPostCache.fetchBlogPostsCached();
}
/**
* Fetch a single blog post with caching
*/
export async function fetchBlogPostCached(dTag: string): Promise {
- // First check if it's in the posts cache
- const cached = cache.get("blog_posts");
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
- const post = cached.data.find((p) => p.dTag === dTag);
- if (post) {
- return post;
- }
- }
-
- // Otherwise fetch it directly
- return fetchBlogPostByDTag(dTag);
+ return blogPostCache.fetchBlogPostCached(dTag);
}
/**
diff --git a/src/lib/server/blog-markdown.test.ts b/src/lib/server/blog-markdown.test.ts
new file mode 100644
index 0000000..6847e2d
--- /dev/null
+++ b/src/lib/server/blog-markdown.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { renderBlogHtml } from "./blog-markdown";
+
+describe("renderBlogHtml", () => {
+ it("removes script tags and inline event handlers", () => {
+ const html = renderBlogHtml(
+ [
+ "# Hello",
+ "",
+ "",
+ '
',
+ ].join("\n")
+ );
+
+ expect(html).toContain("Hello
");
+ expect(html).toContain('
');
+ expect(html).not.toContain("
- {@html ''}
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 46b450f..89224a2 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -145,19 +145,54 @@ function stripHtmlEntities(text: string): string {
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
- "mainEntity": faqs.map(faq => ({
+ mainEntity: faqs.map((faq) => ({
"@type": "Question",
- "name": stripHtmlEntities(faq.question),
- "acceptedAnswer": {
+ name: stripHtmlEntities(faq.question),
+ acceptedAnswer: {
"@type": "Answer",
- "text": stripHtmlEntities(faq.answer)
- }
- }))
+ text: stripHtmlEntities(faq.answer),
+ },
+ })),
};
+
+const faqSchemaJson = JSON.stringify(faqSchema).replace(/
- {@html ''}
+
+
+
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte
index 75cebcf..ec69730 100644
--- a/src/routes/blog/+page.svelte
+++ b/src/routes/blog/+page.svelte
@@ -1,7 +1,7 @@
Blog | White Noise
+
+
@@ -59,4 +89,3 @@ function formatDate(timestamp: number): string {
-
diff --git a/src/routes/blog/[naddr]/+page.server.ts b/src/routes/blog/[naddr]/+page.server.ts
index e556d26..119d1ab 100644
--- a/src/routes/blog/[naddr]/+page.server.ts
+++ b/src/routes/blog/[naddr]/+page.server.ts
@@ -1,5 +1,6 @@
import { error } from "@sveltejs/kit";
import { BLOG_PUBKEY, decodeNaddr, fetchBlogPostCached, KIND_LONG_FORM } from "$lib/nostr";
+import { renderBlogHtml } from "$lib/server/blog-markdown";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
@@ -29,5 +30,6 @@ export const load: PageServerLoad = async ({ params }) => {
return {
post,
+ safeHtml: renderBlogHtml(post.content),
};
};
diff --git a/src/routes/blog/[naddr]/+page.svelte b/src/routes/blog/[naddr]/+page.svelte
index 4b6ce24..b6caeff 100644
--- a/src/routes/blog/[naddr]/+page.svelte
+++ b/src/routes/blog/[naddr]/+page.svelte
@@ -1,19 +1,7 @@
@@ -76,7 +48,10 @@ const blogPostSchema = JSON.stringify(blogPostSchemaObj).replace(/
{/if}
- {@html ''}
+
+
@@ -109,7 +84,7 @@ const blogPostSchema = JSON.stringify(blogPostSchemaObj).replace(/
- {@html safeHtml}
+ {@html data.safeHtml}
@@ -212,4 +187,3 @@ const blogPostSchema = JSON.stringify(blogPostSchemaObj).replace(/
-
diff --git a/src/routes/blog/[naddr]/page.server.test.ts b/src/routes/blog/[naddr]/page.server.test.ts
new file mode 100644
index 0000000..45f5ba3
--- /dev/null
+++ b/src/routes/blog/[naddr]/page.server.test.ts
@@ -0,0 +1,112 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { decodeNaddrMock, fetchBlogPostCachedMock, renderBlogHtmlMock } = vi.hoisted(() => ({
+ decodeNaddrMock: vi.fn(),
+ fetchBlogPostCachedMock: vi.fn(),
+ renderBlogHtmlMock: vi.fn(),
+}));
+
+vi.mock("$lib/nostr", () => ({
+ BLOG_PUBKEY: "expected-pubkey",
+ KIND_LONG_FORM: 30023,
+ decodeNaddr: decodeNaddrMock,
+ fetchBlogPostCached: fetchBlogPostCachedMock,
+}));
+
+vi.mock("$lib/server/blog-markdown", () => ({
+ renderBlogHtml: renderBlogHtmlMock,
+}));
+
+import { load } from "./+page.server";
+
+function createLoadEvent(naddr = "naddr1test"): Parameters
[0] {
+ return {
+ params: { naddr },
+ } as Parameters[0];
+}
+
+describe("blog post page load", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("rejects invalid naddr values", async () => {
+ decodeNaddrMock.mockReturnValue(null);
+
+ await expect(load(createLoadEvent())).rejects.toMatchObject({
+ status: 400,
+ });
+ });
+
+ it("rejects posts for a different pubkey", async () => {
+ decodeNaddrMock.mockReturnValue({
+ dTag: "post",
+ kind: 30023,
+ pubkey: "someone-else",
+ });
+
+ await expect(load(createLoadEvent())).rejects.toMatchObject({
+ status: 404,
+ });
+ });
+
+ it("rejects the wrong event kind", async () => {
+ decodeNaddrMock.mockReturnValue({
+ dTag: "post",
+ kind: 1,
+ pubkey: "expected-pubkey",
+ });
+
+ await expect(load(createLoadEvent())).rejects.toMatchObject({
+ status: 400,
+ });
+ });
+
+ it("rejects missing posts after validation", async () => {
+ decodeNaddrMock.mockReturnValue({
+ dTag: "post",
+ kind: 30023,
+ pubkey: "expected-pubkey",
+ });
+ fetchBlogPostCachedMock.mockResolvedValue(null);
+
+ await expect(load(createLoadEvent())).rejects.toMatchObject({
+ status: 404,
+ });
+ expect(fetchBlogPostCachedMock).toHaveBeenCalledWith("post");
+ });
+
+ it("returns the post and sanitized html for valid requests", async () => {
+ const post = {
+ content: "# Hello",
+ createdAt: 1_700_000_000,
+ dTag: "post",
+ id: "1",
+ image: null,
+ naddr: "naddr1test",
+ pubkey: "expected-pubkey",
+ publishedAt: null,
+ summary: null,
+ tags: [],
+ title: "Hello",
+ };
+
+ decodeNaddrMock.mockReturnValue({
+ dTag: "post",
+ kind: 30023,
+ pubkey: "expected-pubkey",
+ });
+ fetchBlogPostCachedMock.mockResolvedValue(post);
+ renderBlogHtmlMock.mockReturnValue("Hello
");
+
+ await expect(load(createLoadEvent())).resolves.toEqual({
+ post,
+ safeHtml: "Hello
",
+ });
+ expect(renderBlogHtmlMock).toHaveBeenCalledWith("# Hello");
+ });
+});
diff --git a/src/routes/build/+page.svelte b/src/routes/build/+page.svelte
new file mode 100644
index 0000000..c36cd3a
--- /dev/null
+++ b/src/routes/build/+page.svelte
@@ -0,0 +1,356 @@
+
+
+
+ Build with Marmot Protocol | White Noise
+
+
+
+
+
+
+
+
+
+
Build with Marmot
+
+ The Marmot Protocol combines Nostr, MLS, and Blossom for decentralized encrypted messaging. Use the MDK to build compatible clients without implementing the protocol from scratch.
+
+
+
+
+
+
+
+
+ Quick Start (Rust)
+ The MDK (Marmot Development Kit) is a modular Rust SDK. Requires Rust 1.90.0+ and SQLite.
+ git clone https://github.com/marmot-protocol/mdk.git
+cd mdk
+cargo build
+cargo test --features mip04
+
+
+
+
+ MDK Crate Structure
+
+
+
+
+ | Crate |
+ Purpose |
+
+
+
+
+ | mdk-core |
+ Main library: MLS implementation, Nostr integration, group management |
+
+
+ | mdk-storage-traits |
+ Storage abstraction layer (implement for custom backends) |
+
+
+ | mdk-memory-storage |
+ In-memory storage for development and testing |
+
+
+ | mdk-sqlite-storage |
+ SQLite-based persistent storage with encryption (production use) |
+
+
+
+
+
+
+
+
+ Nostr Event Kinds
+ Marmot clients must subscribe to and publish these event kinds:
+
+
+
+
+ | Kind |
+ Purpose |
+ Notes |
+
+
+
+
+ | 443 |
+ KeyPackage |
+ Public invitation card for async group joins. TLS-serialized, base64 encoded. |
+
+
+ | 444 |
+ Welcome |
+ Sent to new members when added to a group. Wrapped in NIP-59 gift wrap. |
+
+
+ | 445 |
+ Group Event |
+ ChaCha20-Poly1305 encrypted group messages (proposals, commits, application messages) published with fresh ephemeral keypairs. |
+
+
+ | 447 |
+ Token Request |
+ Push notification token exchange (MIP-05). |
+
+
+ | 448 |
+ Token List Response |
+ Push notification token list (MIP-05). |
+
+
+ | 449 |
+ Token Removal |
+ Remove push notification token (MIP-05). |
+
+
+ | 10050 |
+ Relay List |
+ User's preferred relays for notifications. |
+
+
+ | 10051 |
+ KeyPackage Relay List |
+ Relays where user publishes KeyPackages. |
+
+
+
+
+
+
+
+
+ Default Ciphersuite
+ All Marmot clients must support this ciphersuite:
+ MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
+ X25519 for key exchange, AES-128-GCM for encryption, SHA-256 for hashing, Ed25519 for signatures.
+
+
+
+
+ Identity Model
+
+ - Nostr keypairs are used for identity (32-byte public key as
BasicCredential)
+ - MLS signing keys are separate from Nostr identity keys
+ - Compromise of a Nostr identity does not give access to MLS group messages
+ - Each device is a separate MLS leaf node (multi-device support)
+
+
+
+
+
+ Basic Client Flow
+
+ - Generate identity: Create or import a Nostr keypair
+ - Publish KeyPackage: Create an MLS KeyPackage and publish it as a kind:443 event to relays listed in the user's kind:10051 event
+ - Create a group: Initialize an MLS group with the Marmot Group Data Extension (0xF2EE), then invite members by consuming their KeyPackages
+ - Send Welcome: After committing an Add proposal, send a Welcome message (kind:444) wrapped in NIP-59 gift wrap
+ - Send messages: Derive the 32-byte group event key from
exporter_secret, encrypt the serialized MLS message with ChaCha20-Poly1305 using a random nonce, then publish base64(nonce || ciphertext) as kind:445 with a fresh ephemeral keypair
+ - Receive messages: Subscribe to kind:445 events filtered by the group's
h tag, decode base64(nonce || ciphertext), decrypt with ChaCha20-Poly1305, and process the MLS message
+
+
+
+
+
+ MIP Status
+ The Marmot Protocol is specified through MIPs (Marmot Improvement Proposals):
+
+
+
+
+ | MIP |
+ Title |
+ Status |
+ Required |
+
+
+
+
+ | MIP-00 |
+ Credentials and KeyPackages |
+ Review |
+ Yes |
+
+
+ | MIP-01 |
+ Group Construction and Marmot Group Data Extension |
+ Review |
+ Yes |
+
+
+ | MIP-02 |
+ Welcome Events |
+ Review |
+ Yes |
+
+
+ | MIP-03 |
+ Group Messages |
+ Review |
+ Yes |
+
+
+ | MIP-04 |
+ Encrypted Media (Blossom + ChaCha20-Poly1305) |
+ Review |
+ No |
+
+
+ | MIP-05 |
+ Push Notifications |
+ Draft |
+ No |
+
+
+
+
+ Full specifications: github.com/marmot-protocol/marmot
+
+
+
+
+ Known Limitations
+
+ - Welcome messages currently exceed relay size limits above approximately 150 group members. Work is underway on "light" Welcome support for larger groups.
+ - The project is in beta. Protocol specifications are in review status.
+
+
+
+
+
+ TypeScript (marmot-ts)
+ For web or Node.js applications, marmot-ts is an early-stage TypeScript implementation:
+ git clone https://github.com/marmot-protocol/marmot-ts.git
+ marmot-ts is under active development and not yet feature-complete.
+
+
+
+
+
+
+
+ Repositories
+
+
+
+
+ | Repository |
+ Purpose |
+ Language |
+ License |
+
+
+
+
+ | whitenoise |
+ Flutter mobile client |
+ Dart |
+ AGPL-3.0 |
+
+
+ | whitenoise-rs |
+ Rust backend library with OpenMLS |
+ Rust |
+ AGPL-3.0 |
+
+
+ | marmot |
+ Protocol specification (MIPs) |
+ N/A |
+ MIT |
+
+
+ | mdk |
+ Marmot Development Kit (modular Rust SDK) |
+ Rust |
+ MIT |
+
+
+ | marmot-ts |
+ TypeScript implementation (early stage) |
+ TypeScript |
+ MIT |
+
+
+
+
+ All repositories: github.com/marmot-protocol
+
+
+
+
diff --git a/src/routes/contribute/+page.svelte b/src/routes/contribute/+page.svelte
index 1c449a5..7b51a89 100644
--- a/src/routes/contribute/+page.svelte
+++ b/src/routes/contribute/+page.svelte
@@ -9,11 +9,56 @@ let bitcoinAddress = $state(
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
+
+const contributeSchema = {
+ "@context": "https://schema.org",
+ "@type": "WebPage",
+ name: "Contribute to White Noise",
+ description:
+ "How to contribute to White Noise through code, design, documentation, bug testing, or financial support.",
+ url: "https://whitenoise.chat/contribute",
+ inLanguage: "en",
+ publisher: {
+ "@type": "Organization",
+ name: "The Marmot Protocol",
+ url: "https://github.com/marmot-protocol",
+ },
+ mainEntity: {
+ "@type": "ItemList",
+ name: "Ways to Contribute",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Community Chat (Signal)",
+ url: "https://signal.group/#CjQKICPlUduq29DjYD_EJQEBwu1EcEMR5QMZqcMlde026LBaEhCGS-kIM7uhNqtwtby57yQ1",
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Source Code (GitHub)",
+ url: "https://github.com/marmot-protocol/whitenoise",
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name: "Bitcoin Donation (Lightning)",
+ url: "lightning:whitenoise@npub.cash",
+ },
+ ],
+ },
+};
+
+const contributeSchemaJson = JSON.stringify(contributeSchema).replace(/
Contribute - White Noise
+
+
@@ -158,4 +203,3 @@ function copyToClipboard(text: string) {
-
diff --git a/src/routes/download/+page.svelte b/src/routes/download/+page.svelte
index 85fae93..fb33858 100644
--- a/src/routes/download/+page.svelte
+++ b/src/routes/download/+page.svelte
@@ -1,29 +1,39 @@
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts
index 867f0a6..aee127b 100644
--- a/src/routes/sitemap.xml/+server.ts
+++ b/src/routes/sitemap.xml/+server.ts
@@ -9,11 +9,17 @@ export const GET: RequestHandler = async () => {
{ path: "/download", changefreq: "monthly", priority: "0.9" },
{ path: "/privacy-matters", changefreq: "yearly", priority: "0.7" },
{ path: "/contribute", changefreq: "monthly", priority: "0.8" },
+ { path: "/build", changefreq: "monthly", priority: "0.8" },
{ path: "/blog", changefreq: "weekly", priority: "0.8" },
];
function escapeXml(s: string): string {
- return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
let blogEntries = "";
@@ -21,9 +27,9 @@ export const GET: RequestHandler = async () => {
const posts = await fetchBlogPostsCached();
for (const post of posts) {
if (post.naddr) {
- const lastmod = new Date(
- (post.publishedAt || post.createdAt) * 1000,
- ).toISOString().split("T")[0];
+ const lastmod = new Date((post.publishedAt || post.createdAt) * 1000)
+ .toISOString()
+ .split("T")[0];
blogEntries += `
${baseUrl}/blog/${escapeXml(post.naddr)}
@@ -48,7 +54,7 @@ ${staticPages
${today}
${page.changefreq}
${page.priority}
- `,
+ `
)
.join("\n")}${blogEntries}
`;
diff --git a/src/routes/sitemap.xml/server.test.ts b/src/routes/sitemap.xml/server.test.ts
new file mode 100644
index 0000000..cbc2798
--- /dev/null
+++ b/src/routes/sitemap.xml/server.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { fetchBlogPostsCachedMock } = vi.hoisted(() => ({
+ fetchBlogPostsCachedMock: vi.fn(),
+}));
+
+vi.mock("$lib/nostr", () => ({
+ fetchBlogPostsCached: fetchBlogPostsCachedMock,
+}));
+
+import { GET } from "./+server";
+
+function createRequestEvent(): Parameters
[0] {
+ return {} as Parameters[0];
+}
+
+describe("sitemap GET", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-23T12:00:00Z"));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("includes static pages and dynamic blog entries", async () => {
+ fetchBlogPostsCachedMock.mockResolvedValue([
+ {
+ content: "hello",
+ createdAt: 1_700_000_000,
+ dTag: "blog-post",
+ id: "1",
+ image: null,
+ naddr: "naddr1blog",
+ pubkey: "pubkey",
+ publishedAt: 1_700_100_000,
+ summary: null,
+ tags: [],
+ title: "Blog",
+ },
+ ]);
+
+ const response = await GET(createRequestEvent());
+ const xml = await response.text();
+
+ expect(response.headers.get("Content-Type")).toBe("application/xml");
+ expect(response.headers.get("Cache-Control")).toBe("max-age=86400");
+ expect(xml).toContain("https://whitenoise.chat/build");
+ expect(xml).toContain("https://whitenoise.chat/blog/naddr1blog");
+ expect(xml).toContain("2023-11-16");
+ });
+
+ it("escapes blog urls in the generated xml", async () => {
+ fetchBlogPostsCachedMock.mockResolvedValue([
+ {
+ content: "hello",
+ createdAt: 1_700_000_000,
+ dTag: "blog-post",
+ id: "1",
+ image: null,
+ naddr: "naddr&<>\"'",
+ pubkey: "pubkey",
+ publishedAt: null,
+ summary: null,
+ tags: [],
+ title: "Blog",
+ },
+ ]);
+
+ const response = await GET(createRequestEvent());
+ const xml = await response.text();
+
+ expect(xml).toContain(
+ "https://whitenoise.chat/blog/naddr&<>"'"
+ );
+ });
+
+ it("still returns a sitemap when fetching blog posts fails", async () => {
+ fetchBlogPostsCachedMock.mockRejectedValue(new Error("relay down"));
+
+ const response = await GET(createRequestEvent());
+ const xml = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(xml).toContain("https://whitenoise.chat/");
+ expect(xml).not.toContain("https://whitenoise.chat/blog/naddr");
+ });
+});
diff --git a/static/llms.txt b/static/llms.txt
index 0332204..817dfeb 100644
--- a/static/llms.txt
+++ b/static/llms.txt
@@ -2,23 +2,31 @@
> White Noise is a decentralized, end-to-end encrypted messenger built on the Marmot Protocol, which combines Nostr (decentralized messaging relays), Blossom (encrypted file storage), and MLS (group encryption). It requires no phone number, email, or account to use. It is open-source, non-profit, and community-driven. Funded by And Other Stuff, OpenSats, and the Human Rights Foundation.
-White Noise provides forward secrecy and post-compromise security for both direct messages and large groups. Messages are encrypted before reaching Nostr relays, so relay operators cannot read them. Media is encrypted client-side and stored on Blossom servers. The network is censorship-resistant because it runs on thousands of independent relays worldwide. Users can self-host relays.
+White Noise provides forward secrecy and post-compromise security for both direct messages and large groups. Messages are encrypted before reaching Nostr relays, so relay operators cannot read them. Media is encrypted client-side and stored on Blossom servers. The network runs on thousands of independent relays worldwide. Users can self-host relays.
## Core Pages
- [Home](https://whitenoise.chat/): Overview of White Noise features, supported-by section, and 14 frequently asked questions
- [Privacy Matters](https://whitenoise.chat/privacy-matters): Essay on digital privacy rights, surveillance threats, and the case for encrypted messaging
- [Download](https://whitenoise.chat/download): Download links for iOS (TestFlight), Android (Zapstore), and Android APK
+- [Build with Marmot](https://whitenoise.chat/build): Developer guide with MDK crate structure, Nostr event kinds, ciphersuite, identity model, and client flow
- [Contribute](https://whitenoise.chat/contribute): How to contribute code, join the community chat, and donate via Bitcoin Lightning or silent payments
- [Blog](https://whitenoise.chat/blog): Project updates and articles published via Nostr (NIP-23 long-form content)
+## Building with Marmot
+
+To build a Marmot-compatible client, use the MDK (Rust) or marmot-ts (TypeScript, early stage). The developer guide covers MDK crate structure, Nostr event kinds (443, 444, 445, 10050, 10051), default ciphersuite, identity model, client flow, and MIP status.
+
+- [Build with Marmot](https://whitenoise.chat/build): Human-readable developer documentation page
+- [Full Developer Reference](https://whitenoise.chat/llms-full.txt): Complete plain-text developer documentation
+
## Marmot Protocol Ecosystem
- [Marmot Protocol Specification](https://github.com/marmot-protocol/marmot): Protocol spec defining MIPs for group messaging over Nostr, Blossom, and MLS (MIT)
- [White Noise App](https://github.com/marmot-protocol/whitenoise): Flutter mobile client (Dart, AGPL-3.0)
- [whitenoise-rs](https://github.com/marmot-protocol/whitenoise-rs): Rust backend library with OpenMLS integration (AGPL-3.0)
- [MDK (Marmot Development Kit)](https://github.com/marmot-protocol/mdk): Modular Rust SDK for building Marmot-compatible clients (MIT)
-- [marmot-ts](https://github.com/marmot-protocol/marmot-ts): TypeScript implementation of Marmot Protocol (early stage)
+- [marmot-ts](https://github.com/marmot-protocol/marmot-ts): TypeScript implementation of Marmot Protocol (early stage, not yet feature-complete)
## Underlying Protocols
@@ -28,12 +36,6 @@ White Noise provides forward secrecy and post-compromise security for both direc
- [OpenMLS](https://github.com/openmls/openmls): Rust implementation of MLS used by whitenoise-rs and MDK
- [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md): Long-form content on Nostr (used for the blog)
-## Building with Marmot
-
-To build a Marmot-compatible client, use the MDK (Rust) or marmot-ts (TypeScript). The full developer guide with MDK crate structure, Nostr event kinds (443, 444, 445, 10050, 10051), default ciphersuite, identity model, client flow, and MIP status is in the full site content file.
-
-- [Full Developer Guide](https://whitenoise.chat/llms-full.txt): Complete "Building with Marmot" section with event kinds, crate structure, and client flow
-
## Optional
- [Full Site Content](https://whitenoise.chat/llms-full.txt): Complete text content of all pages in Markdown format
diff --git a/static/robots.txt b/static/robots.txt
index 09c038e..f367f5a 100644
--- a/static/robots.txt
+++ b/static/robots.txt
@@ -1,6 +1,44 @@
# White Noise - whitenoise.chat
-# All crawlers welcome, including AI agents
+# All crawlers welcome, including AI agents and LLM indexers
+# AI crawlers - explicitly allowed
+User-agent: GPTBot
+Allow: /
+
+User-agent: OAI-SearchBot
+Allow: /
+
+User-agent: ChatGPT-User
+Allow: /
+
+User-agent: anthropic-ai
+Allow: /
+
+User-agent: ClaudeBot
+Allow: /
+
+User-agent: Claude-Web
+Allow: /
+
+User-agent: PerplexityBot
+Allow: /
+
+User-agent: GoogleOther
+Allow: /
+
+User-agent: Google-Extended
+Allow: /
+
+User-agent: Amazonbot
+Allow: /
+
+User-agent: Meta-ExternalAgent
+Allow: /
+
+User-agent: cohere-ai
+Allow: /
+
+# All other crawlers
User-agent: *
Allow: /
@@ -9,4 +47,4 @@ Sitemap: https://whitenoise.chat/sitemap.xml
# LLM-friendly documentation
# /llms.txt - structured navigation for LLMs
-# /llms-full.txt - complete site content in Markdown
+# /llms-full.txt - complete developer guide with Marmot Protocol details
diff --git a/svelte.config.js b/svelte.config.js
index 67f9281..92cd758 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -11,7 +11,9 @@ const config = {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
- adapter: adapter(),
+ adapter: adapter({
+ runtime: "nodejs22.x",
+ }),
},
};
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..8c5c312
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,17 @@
+{
+ "headers": [
+ {
+ "source": "/(.*)",
+ "headers": [
+ {
+ "key": "X-Robots-Tag",
+ "value": "all"
+ },
+ {
+ "key": "Link",
+ "value": "; rel=\"alternate\"; type=\"text/plain\"; title=\"LLM-friendly site overview\", ; rel=\"alternate\"; type=\"text/plain\"; title=\"Complete developer documentation\""
+ }
+ ]
+ }
+ ]
+}