diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index e0a4f42b0..7785b7917 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -112,17 +112,19 @@ Find the right export fast. Load the linked reference for full examples. ### `@webjsdev/core` (browser + isomorphic) - `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`. -- `signal` / `computed` reactive state; `render(v, el)` client render. +- `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render. - `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary. - `Suspense({fallback, children})` page-level streaming; `` component-level streaming. - `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`. - Types: `Metadata`, `PageProps`, `LayoutProps`, `RouteHandlerContext`, `WebjsConfig`. - `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side). -- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`. `Task` lives at `@webjsdev/core/task`, context at `/context`. +- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context. ### `@webjsdev/server` (server side) - `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`. +- Route-handler toolkit: `json(v)` rich responder, `readBody(req)`, `clientIp(req)`, no-arg `headers()` / `cookies()` / `cspNonce()` (client counterpart `richFetch` is in `@webjsdev/core`). See `references/routing-and-pages.md`. +- Auth + sessions: `createAuth` (+ `Credentials` / `Google` / `GitHub`), `auth()` / `auth(req)`, `session()` + `cookieSession` / `storeSession`, `getSession(req)` (`.get` / `.set` / `.flash` / `.destroy`). File storage: `getFileStore` / `diskStore` / `signedUrl`. See `references/auth-and-sessions.md` + `references/built-ins.md`. - Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`). ### File conventions diff --git a/.agents/skills/webjs/references/auth-and-sessions.md b/.agents/skills/webjs/references/auth-and-sessions.md index d91398665..1053ab00d 100644 --- a/.agents/skills/webjs/references/auth-and-sessions.md +++ b/.agents/skills/webjs/references/auth-and-sessions.md @@ -2,10 +2,10 @@ ## What This Covers -- Sessions: cookie by default, Redis-backed when configured, the `SESSION_SECRET` requirement -- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action -- Login and logout flows (`signIn` / `signOut` / `handlers`) -- Protecting a route: gate at the top of a page or action, redirect when unauthenticated +- Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement +- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing +- Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut` +- Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)` - `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files - Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there) - The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie) @@ -21,23 +21,30 @@ scaling, the full caching surface). ## Sessions -Enable sessions in middleware, read and write them in a page or action. +Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps. ```ts -// middleware.ts: enable on all routes -import { session } from '@webjsdev/server'; -export default session(); // auto: REDIS_URL present -> server-side, else -> cookie +// middleware.ts: enable on all routes. Storage is pluggable. +import { session, cookieSession, storeSession } from '@webjsdev/server'; +export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() }); +// cookieSession() -> whole session in a signed cookie (stateless, the default) +// storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie +``` -// in a page or action +`getSession(req)` returns a small key/value `Session` with a METHOD API, not property assignment. Mutating it makes the middleware re-sign and set the cookie on the way out: + +```ts import { getSession } from '@webjsdev/server'; -const s = getSession(req); -s.userId = user.id; // auto-saved after the response +export async function GET(req: Request) { + const s = getSession(req); + s.set('userId', user.id); // write + const id = s.get('userId'); // read + s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read) + s.destroy(); // clear the whole session (logout) +} ``` -Cookie sessions (the default) are signed and encrypted with no server -state. Store sessions (with Redis) keep the session id in the cookie and -the data in Redis. Both require `SESSION_SECRET`, read from the -environment (never a literal in source) so boot fails if it is missing. +Cookie sessions (the default) are signed with no server state; store sessions keep only the id in the cookie and the data in the store. `cookieSession` / `storeSession` are aliases for `cookieSessionStorage` / `storeSessionStorage`. Both strategies require `SESSION_SECRET`, read from the environment (never a literal in source) so boot fails if it is missing. ## Authentication (`createAuth`) @@ -55,7 +62,7 @@ export const { auth, signIn, signOut, handlers } = createAuth({ Credentials({ async authorize(credentials) { const user = await db.query.users.findFirst({ where: { email: credentials.email } }); - if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null; + if (!user || !(await compare(credentials.password, user.passwordHash))) return null; return { id: user.id, name: user.name, email: user.email, role: user.role }; }, }), @@ -63,9 +70,50 @@ export const { auth, signIn, signOut, handlers } = createAuth({ GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET ], secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env + pages: { error: '/login' }, // a failed sign-in 302s here with ?error= }); ``` +**Password hashing is the app's job** (WebJs ships no `verifyPassword`). Use `scrypt` from `node:crypto` (built into Node AND Bun, no dependency) in a server-only utility, and call it from `authorize`: + +```ts +// modules/auth/password.server.ts (a server-only utility, never reaches the browser) +import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +const scryptAsync = promisify(scrypt); +export async function hash(pw: string) { + const salt = randomBytes(16).toString('hex'); + return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex'); +} +export async function compare(pw: string, stored: string) { + const [salt, key] = stored.split(':'); + return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex')); +} +``` + +**Mount `handlers` at an `app/api/auth/[...path]/route.ts` catch-all** (at the app root, NOT under a feature folder): `createAuth` hardcodes `/api/auth/signin/*` and `/api/auth/callback/*` for its form posts and OAuth callback URIs. + +```ts +// app/api/auth/[...path]/route.ts +import { handlers } from '#modules/auth/auth.server.ts'; +export const GET = handlers.GET; +export const POST = handlers.POST; +``` + +**The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`: + +```html +
+ + + +
+ +
+``` + +For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly. + Sessions are JWT by default (stateless, scales horizontally). OAuth providers handle the full redirect flow. Read the session anywhere on the server with `auth()`. @@ -119,6 +167,20 @@ Reading the session through `auth()` also auto-excludes the page from the server HTML response cache, so a per-user page is never cached and served to another visitor (see `built-ins.md`). +To gate a WHOLE subtree in one place, use a per-segment `middleware.ts` that reads `auth(req)` (the explicit-request form) and returns a `302` BEFORE the page renders. It runs for every request under its segment and needs only a cookie read (no DB query), so the gate is real the moment the app boots: + +```ts +// app/dashboard/middleware.ts (protects /dashboard/*) +import { auth } from '#modules/auth/auth.server.ts'; +export default async function requireAuth(req: Request, next: () => Promise) { + const session = await auth(req); + if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } }); + return next(); +} +``` + +`auth(req)` takes the in-flight request explicitly (for a middleware / route); the ambient `auth()` (no argument) reads from context inside a page or action. + ## `forbidden()` (403) vs `unauthorized()` (401) Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()` diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 58f554afb..fef6dbb5e 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -4,7 +4,7 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json ## What This Covers -- **Environment variables** and the `WEBJS_PUBLIC_` browser-exposed prefix. +- **Environment variables**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation. - **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag). - **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets). - **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs. @@ -25,6 +25,18 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`. +**Validate required vars at boot with an app-root `env.ts`** (optional). It default-exports either a SCHEMA object (each var mapped to a type `string` / `number` / `boolean` / `url` / `enum`, or an options object with `optional` / `default` / `minLength` / `pattern` / `values`) OR a validator function `(env) => void` that throws. It runs at boot after `.env` loads, coerces values and writes defaults back to `process.env`, and fails fast naming EVERY bad var: + +```ts +// env.ts +export default { + DATABASE_URL: 'url', + SESSION_SECRET: { type: 'string', minLength: 16 }, + PORT: { type: 'number', default: 8080 }, + LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' }, +}; +``` + ## Caching ### `cache()` for query and computation results @@ -112,7 +124,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' })); **Never trust a user filename as a key.** `generateKey(file.name)` returns an opaque `.` with a sanitized extension; a traversal attempt yields a bare safe key. Keys are containment-checked before any filesystem op. -**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. +**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. Pass `base` to point the signed link at your own serve route instead of the default upload URL: `signedUrl(key, { secret, base: '/files/' + key, expiresIn: 3600 })`. **Serving-XSS warning.** The recorded content-type is attacker-controlled (the browser sent it at upload). A serving route MUST send `X-Content-Type-Options: nosniff` and SHOULD send `Content-Disposition: attachment` for user uploads. Only serve inline after validating bytes against a strict inert allowlist, never `text/html` / `image/svg+xml`. Add the uploads directory to `.gitignore`. diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 2f896cb23..dbe7fecbe 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -35,10 +35,13 @@ Note for anyone testing this: **the Chromium web-test-runner currently resolves ``` ```js -import { disableClientRouter } from '@webjsdev/core'; -disableClientRouter(); +import { disableClientRouter, enableClientRouter } from '@webjsdev/core'; +disableClientRouter(); // stop intercepting document /
(plain links resume full loads) +enableClientRouter(); // turn soft navigation back on ``` +`disableClientRouter()` / `enableClientRouter()` are a runtime pair that toggle only the document-level `` / `` interception. An explicit `navigate(url)` call still does a soft navigation either way (it is not gated by the toggle). + Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped. **Programmatic navigation and cache eviction.** @@ -113,6 +116,13 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A ``` +A page (or layout) does not write raw `` markup, so emit that meta through the `other` metadata field, which scopes it to the page that declares it: + +```ts +// app/gallery/page.ts +export const metadata = { other: { 'view-transition': 'same-origin' } }; +``` + The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `