diff --git a/website/AGENTS.md b/website/AGENTS.md index 78f07ef7e..97dbb9044 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -38,7 +38,25 @@ website/ docs/ /docs/, the reference documentation (#1098 moved it here from docs.webjs.dev). layout.ts holds the nav tree + docs-scoped metadata; the shell itself is shared, - see lib/ui/docs-shell.ts. + see lib/ui/docs-shell.ts. Nav labels and section titles + are Title Case (the Next.js and Rails convention, not + Tailwind's sentence case). A word whose casing is fixed + by something other than prose keeps it, which covers + code tokens (createAuth, @webjsdev/ui) and brands that + start lowercase (macOS, npm). Recasing either one + misspells it. Casing is the only thing pinned + sidebar-wide: a nav label NEED NOT match the page's + own h1, several deliberately do not, and those are + correct as they stand. The one exception is the + /docs/auth and /docs/authentication pair, where each + page's h1 is held byte-equal to its OWN label after + one of them once rendered a heading that named the + other page (#1103). + Do NOT generalise that pin to other pages. The /ui + sidebar is exempt from all of the above, since its + labels are component names from the live registry. + Both rules are enforced in test/ssr/docs-links.test.ts, + which carries the specifics and the current counts. ui/ /ui, the @webjsdev/ui component gallery (#1099 moved it here from ui.webjs.dev). page.ts is the introduction, [name]/page.ts one page per component, layout.ts the diff --git a/website/app/docs/auth/page.ts b/website/app/docs/auth/page.ts index c11e4c8c0..3f8b6cc26 100644 --- a/website/app/docs/auth/page.ts +++ b/website/app/docs/auth/page.ts @@ -8,9 +8,9 @@ export const metadata = { export default function Auth() { return html` -

Auth providers (createAuth)

+

Auth Providers (createAuth)

WebJs ships createAuth(), a NextAuth-style auth surface with OAuth providers, credentials login, and JWT sessions. Reach for this page when you want providers and sessions handled for you, and no external auth library in the dependency tree.

-

It is not the only route. If you want to own the session format, the password hashing, and the route-protection rules yourself, build on the framework primitives instead, which is what Build your own authentication covers and what the blog example does. Pick createAuth() for OAuth and a batteries-included setup, pick the primitives when you want full control of the session.

+

It is not the only route. If you want to own the session format, the password hashing, and the route-protection rules yourself, build on the framework primitives instead, which is what Build Your Own Authentication covers and what the blog example does. Pick createAuth() for OAuth and a batteries-included setup, pick the primitives when you want full control of the session.

Setup

// lib/auth.server.ts: create once diff --git a/website/app/docs/authentication/page.ts b/website/app/docs/authentication/page.ts index 538aa2edc..81f4acc74 100644 --- a/website/app/docs/authentication/page.ts +++ b/website/app/docs/authentication/page.ts @@ -8,9 +8,9 @@ export const metadata = { export default function Authentication() { return html` -

Build your own authentication

+

Build Your Own Authentication

This page is the hand-rolled route: session-based auth built on the framework primitives, where you own the session format, the password hashing, and the route-protection rules. The blog example is a complete implementation of it, using scrypt password hashing, session tokens in cookies, and middleware-based route protection.

-

WebJs does ship a batteries-included option, so this is a choice rather than a necessity. Auth providers (createAuth) gives you OAuth providers, credentials login, and JWT sessions out of the box. Take that page if you want OAuth or do not want to own the session. Stay here if you want full control over how a session is minted, stored, and checked.

+

WebJs does ship a batteries-included option, so this is a choice rather than a necessity. Auth Providers (createAuth) gives you OAuth providers, credentials login, and JWT sessions out of the box. Take that page if you want OAuth or do not want to own the session. Stay here if you want full control over how a session is minted, stored, and checked.

Architecture

lib/ diff --git a/website/app/docs/cache/page.ts b/website/app/docs/cache/page.ts index c45fc0aa0..38da3d532 100644 --- a/website/app/docs/cache/page.ts +++ b/website/app/docs/cache/page.ts @@ -199,7 +199,7 @@ setStore(redisStore({ url: process.env.REDIS_URL }));

Next Steps

`; diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts index aa7f8576e..cdc1fbfd0 100644 --- a/website/app/docs/layout.ts +++ b/website/app/docs/layout.ts @@ -22,7 +22,7 @@ import '#components/code-block.ts'; * * Doc page bodies are plain HTML with no component wrapper, so their * typography is styled through the shell's `.prose-docs` rules rather than - * per-element utility classes across 45 pages. + * per-element utility classes across 43 pages. */ const NAV_SECTIONS = [ { @@ -61,7 +61,7 @@ const NAV_SECTIONS = [ { href: '/docs/api-routes', label: 'API Routes' }, { href: '/docs/websockets', label: 'WebSockets' }, { href: '/docs/database', label: 'Database (Drizzle)' }, - { href: '/docs/authentication', label: 'Build your own authentication' }, + { href: '/docs/authentication', label: 'Build Your Own Authentication' }, { href: '/docs/backend-only', label: 'Backend-Only Mode' }, ], }, @@ -71,7 +71,7 @@ const NAV_SECTIONS = [ { href: '/docs/cache', label: 'Caching' }, { href: '/docs/file-storage', label: 'File Storage' }, { href: '/docs/sessions', label: 'Sessions' }, - { href: '/docs/auth', label: 'Auth providers (createAuth)' }, + { href: '/docs/auth', label: 'Auth Providers (createAuth)' }, { href: '/docs/rate-limiting', label: 'Rate Limiting' }, { href: '/docs/security', label: 'Security' }, { href: '/docs/metadata-routes', label: 'Metadata Routes' }, @@ -114,7 +114,7 @@ const NAV_SECTIONS = [ * one field each page sets. * * Without this the docs inherit the marketing pitch: the deleted docs root - * layout carried its own title and description, so dropping it left all 45 + * layout carried its own title and description, so dropping it left all 43 * pages advertising "the web framework for AI agents" as their search snippet * and social card. * diff --git a/website/app/docs/rate-limiting/page.ts b/website/app/docs/rate-limiting/page.ts index 7c84b00cd..91ff701ea 100644 --- a/website/app/docs/rate-limiting/page.ts +++ b/website/app/docs/rate-limiting/page.ts @@ -113,7 +113,7 @@ REDIS_URL=redis://localhost:6379
`; } diff --git a/website/app/docs/security/page.ts b/website/app/docs/security/page.ts index 7ce66bfc5..a3d1b0cb6 100644 --- a/website/app/docs/security/page.ts +++ b/website/app/docs/security/page.ts @@ -73,7 +73,7 @@ export default function Security() {

Cross-origin vendor modules (resolved from jspm.io) carry a standard SRI integrity hash so a swapped or compromised CDN response cannot execute unverified. This now applies on both paths: a pinned app (webjs vendor pin) ships the hashes in its committed importmap, and an un-pinned app computes them live at warmup. SRI computation is fail-open: a CDN fetch failure during the live path skips that one hash with a warning rather than taking the app down. For reproducible hashes and zero warmup fetches, pin. See No-Build Model.

Sessions and secret management

-

Session cookies are signed, so set a strong AUTH_SECRET (and SESSION_SECRET where used), 32 or more random characters, in production. Keep all secrets server-only: any process.env name WITHOUT the WEBJS_PUBLIC_ prefix never reaches the browser (reading process.env.DATABASE_URL from a component returns undefined, the same as a typo). The prefix is fail-closed, so a secret cannot leak by accident. Prefer your platform's secret injection over a committed .env file. See Sessions and Auth providers (createAuth).

+

Session cookies are signed, so set a strong AUTH_SECRET (and SESSION_SECRET where used), 32 or more random characters, in production. Keep all secrets server-only: any process.env name WITHOUT the WEBJS_PUBLIC_ prefix never reaches the browser (reading process.env.DATABASE_URL from a component returns undefined, the same as a typo). The prefix is fail-closed, so a secret cannot leak by accident. Prefer your platform's secret injection over a committed .env file. See Sessions and Auth Providers (createAuth).

Rate limiting

Protect auth endpoints and other abuse-prone routes with the rateLimit({ window, max }) middleware, placed at any route level (it applies to that subtree). Behind a reverse proxy or CDN, set trustProxy: true so it keys on the forwarded client IP, make sure the proxy strips an inbound X-Forwarded-For before adding its own, and leave WEBJS_NO_TRUST_PROXY=1 unset, since that env var outranks the option and puts the limiter back on the stamped socket IP. See Rate Limiting.

diff --git a/website/app/docs/sessions/page.ts b/website/app/docs/sessions/page.ts index 4c44bf044..337e294e1 100644 --- a/website/app/docs/sessions/page.ts +++ b/website/app/docs/sessions/page.ts @@ -152,7 +152,7 @@ const message = s.get('success'); // 'Post published!', only this requestNext Steps `; diff --git a/website/app/docs/typescript/page.ts b/website/app/docs/typescript/page.ts index 5dc840865..4bd71b250 100644 --- a/website/app/docs/typescript/page.ts +++ b/website/app/docs/typescript/page.ts @@ -201,7 +201,7 @@ events[0].createdAt.toLocaleDateString(); // works const { auth } = createAuth<AppUser>({ secret, providers }); const session = await auth(); session?.user.role; // typed, no cast -

Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth providers (createAuth).

+

Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth Providers (createAuth).

JSDoc Alternative

If you prefer .js files, you can achieve the same type safety using JSDoc annotations with checkJs: true in your tsconfig:

diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index d22e0a2e7..88ea18d7c 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -37,7 +37,7 @@ before(async () => { * * Two sources, and the second is the one that matters most. Doc page prose * yields around 36 distinct slugs, but the SIDEBAR is the only surface that - * links all 45, and its hrefs are single-quoted object literals rendered + * links all 43, and its hrefs are single-quoted object literals rendered * through a template hole, so a walk that only reads `href="..."` in page * files cannot see them. A typo there would ship a 404 in the primary * navigation of every docs page with this test green. @@ -76,7 +76,7 @@ async function internalDocLinks(): Promise<{ from: string; href: string }[]> { test('every internal /docs link the docs publish resolves', async () => { const links = await internalDocLinks(); - // The sidebar alone contributes 45, so a floor well above that proves both + // The sidebar alone contributes 43, so a floor well above that proves both // sources were actually read rather than one silently yielding nothing. assert.ok(links.length > 60, `sanity: expected many internal links, found ${links.length}`); assert.ok( @@ -174,11 +174,22 @@ test('no two doc pages declare the same metadata title', async () => { }); test('a doc page h1 matches its sidebar label', async () => { - // The pair above also disagreed with their own nav entries ('Auth - // (Providers)' and 'Authentication' over two

Authentication

s), so - // neither entry matched the heading a reader landed on. Scoped to the two - // pages the mismatch was found on rather than all of them, because the rest - // of the docs use a deliberately shorter nav label than their heading. + // Only one of the pair ever disagreed with its own nav entry. Both + // rendered

Authentication

. /docs/authentication was labelled + // 'Authentication', so it agreed with itself. /docs/auth was labelled + // 'Auth (Providers)' and rendered a heading byte-identical to its SIBLING's + // label, so clicking one nav entry landed the reader on a heading naming + // the other page. Both slugs are pinned rather than just /docs/auth because + // the collision was between one page's h1 and the OTHER's label, so pinning + // half of it would leave the other half free to drift back into it. + // + // Scoped to these two rather than every page, and NOT because the rest + // diverge. Most doc entries already read the same in both places. It is + // scoped because five of the 41 unpinned pages would fail a byte-equal + // check: getting-started, runtime, task and editor-setup deliberately use + // a label that differs from their heading, and conventions reads the same + // but escapes its ampersand in the h1, so it compares unequal. All five + // are correct as they stand, so widening this test reds on working pages. const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); const labelFor = (href: string) => { const m = layout.match(new RegExp(`href:\\s*'${href}',\\s*label:\\s*'([^']+)'`)); @@ -194,6 +205,154 @@ test('a doc page h1 matches its sidebar label', async () => { } }); +test('every docs sidebar label and section title is Title Case', async () => { + // Two labels drifted to sentence case ('Build your own authentication' and + // 'Auth providers (createAuth)') against 42 Title Case siblings, and nothing + // caught it because casing is not a link, a title, or an order. The rule is + // the one comparable docs sites converge on: PROSE takes the project's + // convention, and a word whose casing is fixed by something else is written + // verbatim and never recased. That second half covers code tokens + // ('createAuth', '@webjsdev/ui') and brands that start lowercase ('macOS', + // 'npm') alike, since recasing either one misspells it. + // Qwik ships both 'API Reference' and 'API reference' in one sidebar because + // neither half was ever written down. + // + // This is a FLOOR, not a Title Case parser. It asserts each word STARTS with + // a capital, so 'Build YOUR Own Authentication' passes. That is deliberate: + // the drift it exists to catch is sentence case, and a stricter rule is one + // people delete the first time it fires on something legitimate. + + // Words a title-case scheme leaves lowercase after the first position. + // Generous on purpose, so this PERMITS both 'Deploying With Docker' and + // 'Deploying with Docker'. Only 'Migrating from Next.js' exercises it today. + // The rest are here so the first 'Deploying on Railway' does not red CI for a + // label that was never wrong. + // + // This list is matched case-INSENSITIVELY while FIXED_CASING is matched + // exact-case, so an entry here whose spelling collides with a fixed-casing + // word permits that word's DRIFTED form. The correctly-cased word is fine + // either way, since FIXED_CASING is checked first, but the drift is not: + // FIXED_CASING never matches 'vs', so with 'vs' in this list a slip from + // 'VS Code' to 'vs Code' reads as an ordinary minor word and passes. That + // is why 'vs' is absent. The assertion below enforces it rather than + // trusting this comment, because remedy 3 of the failure message sends the + // next person here to add exactly this kind of word. + const MINOR_WORDS = new Set([ + 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'if', 'in', 'into', + 'nor', 'of', 'off', 'on', 'onto', 'or', 'over', 'per', 'so', 'the', 'to', + 'up', 'via', 'with', 'yet', + ]); + + // Words whose casing is fixed by something other than prose, so recasing + // them would be WRONG rather than a correction. Two kinds qualify: a code + // token ('@webjsdev/ui', 'createAuth', 'webjs check', 'package.json') and a + // brand with non-prose capitalisation ('macOS', 'iOS', 'npm', and the VS of + // 'VS Code'). No structural rule + // can carry this: shape detects '@webjsdev/ui' and 'createAuth', but a + // future 'webjs check' label is two ordinary lowercase words, + // byte-indistinguishable from the slip this test hunts. So the exemption is + // a named list, and adding to it is the deliberate act that records "this + // spelling is correct, not a slip". Matched against the + // word with wrapping punctuation stripped, so it survives a rename to + // 'Auth Providers (createAuth API)'. + const FIXED_CASING = new Set(['createAuth', '@webjsdev/ui', 'VS']); + + // The two sets must not collide, or the MINOR_WORDS entry permits the drifted + // spelling and the guard goes quiet on it. Asserted rather than left to the + // comment above, since 'vs' sat in MINOR_WORDS and let 'vs Code' pass until + // it was measured. + const collisions = [...FIXED_CASING].filter((w) => MINOR_WORDS.has(w.toLowerCase())); + assert.deepEqual( + collisions, + [], + `these words are in FIXED_CASING and also in MINOR_WORDS: ${collisions.join(', ')}. MINOR_WORDS is matched on the lowercased token, so it permits the drifted spelling of each one. Remove them from MINOR_WORDS`, + ); + + const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); + // Slice to the NAV_SECTIONS literal. Outside it sit the docs-scoped metadata + // block (its own `title:` keys) and the shell call's aria labels + // ('Documentation', 'Documentation menu'), none of which are nav text and the + // last of which is legitimately sentence case. + const start = layout.indexOf('const NAV_SECTIONS'); + const end = layout.indexOf('\n];', start); + assert.ok(start !== -1 && end > start, 'could not locate the NAV_SECTIONS literal in layout.ts'); + const nav = layout.slice(start, end); + assert.ok(!nav.includes('generateMetadata'), 'the NAV_SECTIONS slice ran past the end of the literal'); + + // Three deliberate choices in this one regex, each closing a way an entry + // could go unread. Stated as what the pattern DOES, not as a promise about + // which assertion catches a given malformed entry: the parse degrades + // differently depending on which key is malformed and in what order the keys + // are written, so any such promise would be true for some shapes only. + // + // Each key is read on its own rather than anchored to a neighbour, since an + // `href:`-anchored pattern reads nothing from `{ label: '...', href: '...' }`. + // The quote character is captured and back-referenced rather than hard-coded, + // so both quote styles parse and a value may carry the other quote inside it. + // And it takes `+` rather than `*`, so an empty value does not quietly become + // an empty string that the has-a-letter test below then skips. + // + // The href count is a cross-check on the label count. It is a useful signal, + // not a guarantee: a malformed entry can also surface as a garbage row in the + // offender list instead, which is equally loud and names the entry. + const quoted = (key: string) => new RegExp(`\\b${key}:\\s*(['"])((?:\\\\.|(?!\\1).)+)\\1`, 'g'); + const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); + const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); + const hrefs = [...nav.matchAll(quoted('href'))].map((m) => m[2]); + // Every section object carries exactly one `items:`, so this counts sections + // independently of their titles, the way hrefs count entries independently + // of their labels. + const sections = [...nav.matchAll(/\bitems:/g)].length; + + assert.equal( + labels.length, + hrefs.length, + `parsed ${hrefs.length} hrefs but ${labels.length} labels: a nav entry did not parse, so it is not being checked. Usual causes are an empty value or a quoting style this regex does not read. Fix the nav entry if it is malformed; widen the regex only if the entry is legitimate`, + ); + assert.equal( + titles.length, + sections, + `parsed ${sections} sections but ${titles.length} section titles: a section title did not parse, so it is not being checked. Same causes as above. Without this a section title could go empty and only drop the count, which the floor below would still clear`, + ); + // Floors matching the sibling checks in this file, so a regex that stops + // matching fails here instead of passing empty. + assert.ok(labels.length > 40, `sanity: expected the full sidebar, parsed ${labels.length} labels`); + assert.ok(titles.length > 3, `sanity: expected every section, parsed ${titles.length} titles`); + + const offenders: string[] = []; + for (const value of [...titles, ...labels]) { + value.split(/\s+/).forEach((token, i) => { + // 'Runtime (Node & Bun)', 'Editor Setup (Neovim, VS Code)', 'cache()' + const word = token.replace(/^\(+|[)(,.]+$/g, ''); + if (!/[A-Za-z]/.test(word)) return; // the bare & in 'Streaming & Suspense' + if (FIXED_CASING.has(word)) return; + if (i > 0 && MINOR_WORDS.has(word.toLowerCase())) return; + if (!/^[A-Z]/.test(word)) offenders.push(`${value} -> '${word}'`); + }); + } + + assert.deepEqual( + offenders, + [], + 'these docs sidebar entries are not Title Case (entry -> the lowercase word):\n ' + + offenders.join('\n ') + + '\n\nThe docs sidebar is Title Case throughout, section titles included. Pick the fix that matches the word:\n' + + ' 1. ORDINARY WORD: recase it in app/docs/layout.ts. This is the fix nearly every\n' + + ' hit wants, and it is what the last two drifts needed.\n' + + ' 2. A WORD WHOSE CASING IS NOT PROSE: add it to FIXED_CASING at the top of\n' + + ' this test, spelled EXACTLY as this message printed it above. Wrapping\n' + + " brackets and trailing punctuation are stripped before the lookup, so a\n" + + " 'cache()' in a label is listed as 'cache'. Two kinds belong there: a code\n" + + " token (a package like '@webjsdev/ui', an export like 'createAuth', a\n" + + " command like 'webjs check', a filename like 'package.json') and a brand\n" + + " that starts lowercase ('macOS', 'iOS', 'npm'). For both, recasing would\n" + + " MISSPELL the word, so the label is right and this test is what needs\n" + + ' updating. Add each word of a multi-word command separately.\n' + + " 3. LOWERCASE-IN-TITLE WORD this list does not know yet ('amid', 'until'): add it\n" + + ' to MINOR_WORDS instead.', + ); +}); + test('the llms.txt index follows the sidebar order', async () => { // The order used to be a hand-copied list that had already drifted from the // sidebar, so the AI-facing index put Runtime and Security in an