@webjsdev/mcp 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -103
- package/package.json +5 -10
- package/src/brief.js +52 -0
- package/src/catalog.js +82 -0
- package/src/mcp-init.js +139 -0
- package/src/mcp-source.js +94 -8
- package/src/mcp.js +96 -164
- package/resources/AGENTS.md +0 -701
- package/resources/SKILL.md +0 -286
- package/resources/corpus.json +0 -6
- package/resources/references/auth-and-sessions.md +0 -300
- package/resources/references/built-ins.md +0 -336
- package/resources/references/client-router-and-streaming.md +0 -313
- package/resources/references/components.md +0 -469
- package/resources/references/data-and-actions.md +0 -319
- package/resources/references/module-structure.md +0 -229
- package/resources/references/muscle-memory-gotchas.md +0 -341
- package/resources/references/optimistic-ui.md +0 -179
- package/resources/references/routing-and-pages.md +0 -323
- package/resources/references/runtime.md +0 -108
- package/resources/references/service-worker.md +0 -80
- package/resources/references/styling.md +0 -295
- package/resources/references/testing.md +0 -223
- package/resources/references/typescript.md +0 -231
- package/resources/references/ui-kit.md +0 -127
- package/src/mcp-docs.js +0 -641
package/resources/SKILL.md
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: webjs
|
|
3
|
-
description: Build and review WebJs applications. Use when working on WebJs app structure, pages, layouts, routes, server actions, components, signals, data and validation, auth, sessions, styling, the client router, streaming, or tests. WebJs is AI-first, web-components-first, and has no build step.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Build a WebJs App
|
|
7
|
-
|
|
8
|
-
Use this skill for end-to-end WebJs app work. It helps you choose the right layer first, reach for the right export, and avoid the WebJs-specific mistakes that Next.js or Lit muscle memory causes. WebJs is its own framework: the component API matches Lit and the routing feels like Next, but the execution model is neither.
|
|
9
|
-
|
|
10
|
-
## Full Documentation
|
|
11
|
-
|
|
12
|
-
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). To check ONE export's contract, run `npx webjs source <Export>` (for example `npx webjs source createAuth`, or `--pkg core` to narrow): it prints that export's signature and the doc comment above it from the installed package, so you read the declaration instead of the file. The MCP `source` tool does the same with `export`. Open the source file when the question is about behaviour the signature does not state. The complete hosted docs live at https://webjs.dev/docs.
|
|
13
|
-
|
|
14
|
-
## What WebJs Is
|
|
15
|
-
|
|
16
|
-
WebJs is an AI-first, web-components-first framework with **no build step**: source files are served as native ES modules, and TypeScript is stripped in place (Node 24+ or Bun). It runs SSR + progressive enhancement by default.
|
|
17
|
-
|
|
18
|
-
**There is no server/client component split.** No RSC render tree, no Flight protocol, no `"use client"` boundary. Instead:
|
|
19
|
-
|
|
20
|
-
- **Pages and layouts** (`app/**/page.ts`, `app/**/layout.ts`) run **only on the server** to produce HTML. They do NOT hydrate, so their own markup cannot be interactive (an `@click` in a page template is dropped at SSR). They still LOAD in the browser so imported components register.
|
|
21
|
-
- **Components** (`WebComponent` custom elements) hydrate per element, islands-style. **All interactivity lives here**: `@event`, reactive property assignment, signal mutation.
|
|
22
|
-
- **`*.server.ts`** is the one server boundary. With `'use server'` its exports are RPC-callable from the client (the import is rewritten to a stub); without it the file is a server-only utility whose browser import throws at load. This, not a component annotation, is how a dependency (the DB driver, secrets, `node:*`) is kept off the client.
|
|
23
|
-
- **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic.
|
|
24
|
-
|
|
25
|
-
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
|
|
26
|
-
|
|
27
|
-
**Islands, and why their size is a decision.** A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. `references/components.md` has the stopping rule and a worked before/after.
|
|
28
|
-
|
|
29
|
-
## When To Use This Skill
|
|
30
|
-
|
|
31
|
-
- New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
|
|
32
|
-
- Reviewing WebJs code for correctness or framework usage
|
|
33
|
-
- Answering "how should this be structured in WebJs?"
|
|
34
|
-
- Finding the right export, reference doc, or default pattern for a task
|
|
35
|
-
|
|
36
|
-
## Reach For The Right Primitive
|
|
37
|
-
|
|
38
|
-
Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the React-shaped version gets hand-rolled in its place. That version compiles, passes `webjs check`, and ships, so nothing catches it. The "reflex to resist" column is what the hand-rolled version usually looks like.
|
|
39
|
-
|
|
40
|
-
Rows point rather than explain. The reference is the authority on the rule, and it is the durable half: the demo lives in the scaffold gallery, which an app deletes with `npm run gallery:clear` once it has outgrown it.
|
|
41
|
-
|
|
42
|
-
| I need to... | Reach for | Reflex to resist | Reference | Demo |
|
|
43
|
-
| --- | --- | --- | --- | --- |
|
|
44
|
-
| add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
|
|
45
|
-
| abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
|
|
46
|
-
| set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
|
|
47
|
-
| make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
|
|
48
|
-
| render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
|
|
49
|
-
| get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
|
|
50
|
-
| call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` |
|
|
51
|
-
| expose JSON to a caller outside the app | `route.ts` with named `GET` / `POST` exports | a server action, which is the in-app path | `references/routing-and-pages.md` | `app/features/route-handler` |
|
|
52
|
-
| write data from a form, JS off included | `<form action=${importedAction}>` | an `@submit` handler calling `fetch()` | `references/data-and-actions.md` | `app/features/forms` |
|
|
53
|
-
| make a mutation feel instant | `optimistic()` | a manual try-catch that restores a cached copy | `references/optimistic-ui.md` | `app/features/optimistic-ui` |
|
|
54
|
-
| navigate without a full page reload | nothing, the router is already on | importing or configuring a router | `references/client-router-and-streaming.md` | `app/features/client-router` |
|
|
55
|
-
| cross-fade a navigation instead of snapping | the `view-transition` meta, via page metadata | animating the swap yourself | `references/client-router-and-streaming.md` | `app/features/view-transitions` |
|
|
56
|
-
| show tokens or progress as the server produces them | an action returning an async generator | polling, or a socket for a one-shot answer | `references/client-router-and-streaming.md` | `app/features/streaming` |
|
|
57
|
-
| change ONE element after a write | `<webjs-stream>` | redrawing the whole list around it | `references/client-router-and-streaming.md` | `app/features/stream` |
|
|
58
|
-
| paint the page before a slow region is ready | `<webjs-suspense>` with a fallback | blocking the whole page on the slow await | `references/client-router-and-streaming.md` | `app/features/suspense` |
|
|
59
|
-
| refresh one region on its own, with no navigation | `<webjs-frame>` | a stateful component that fetches and re-renders | `references/client-router-and-streaming.md` | `app/features/frames` |
|
|
60
|
-
| hold a live two-way connection | a `WS()` route export plus `connectWS()` | polling on an interval | `references/client-router-and-streaming.md` | `app/features/websockets` |
|
|
61
|
-
| push one update to every client on a socket path | `broadcast()` | every client polling for changes | `references/client-router-and-streaming.md` | `app/features/broadcast` |
|
|
62
|
-
| add login and a signed-in-only route | `createAuth` plus a redirect in the page | rolling password hashing and session cookies | `references/auth-and-sessions.md` | `app/features/auth` |
|
|
63
|
-
| remember something per visitor across requests | `getSession()` on a signed cookie | a module-level map keyed by user | `references/auth-and-sessions.md` | `app/features/sessions` |
|
|
64
|
-
| stop re-rendering a page identical for everyone | `export const revalidate` | caching by hand in a module variable | `references/built-ins.md` | `app/features/caching` |
|
|
65
|
-
| read config or a secret at runtime | `process.env` server-side, `WEBJS_PUBLIC_` for the browser | importing a config module into a component | `references/built-ins.md` | `app/features/env` |
|
|
66
|
-
| stop one caller hammering an endpoint | the `rateLimit()` middleware | counting requests inside the handler | `references/built-ins.md` | `app/features/rate-limit` |
|
|
67
|
-
| accept an upload and serve it back | `FileStore` plus a streaming route | buffering the file in memory or writing to `public/` | `references/built-ins.md` | `app/features/file-storage` |
|
|
68
|
-
| keep the app usable offline | the opt-in service worker | caching responses in `localStorage` | `references/service-worker.md` | `app/features/service-worker` |
|
|
69
|
-
| see these composed in one real feature | the todo example app | stitching the single-feature demos together | `references/optimistic-ui.md` | `app/examples/todo` |
|
|
70
|
-
|
|
71
|
-
## Load Only The References You Need
|
|
72
|
-
|
|
73
|
-
The table above routes by the job; this one routes by the topic, for when you already know which surface you are working on. Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
74
|
-
|
|
75
|
-
| Task involves... | Start with |
|
|
76
|
-
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
77
|
-
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
78
|
-
| Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
79
|
-
| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
|
|
80
|
-
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
81
|
-
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
82
|
-
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
83
|
-
| Where a repeated markup helper lives (`utils/ui/` vs `lib/`), and fragment vs display-only component | `references/styling.md` |
|
|
84
|
-
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
85
|
-
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
86
|
-
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
|
|
87
|
-
| TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | `references/typescript.md` |
|
|
88
|
-
| Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
|
|
89
|
-
| Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
|
|
90
|
-
| Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
|
|
91
|
-
| Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` |
|
|
92
|
-
| Splitting a large file, or how big a module may be | `references/module-structure.md` |
|
|
93
|
-
| A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` |
|
|
94
|
-
|
|
95
|
-
Common bundles:
|
|
96
|
-
|
|
97
|
-
- **Form or CRUD feature** then data-and-actions, routing-and-pages, testing; add auth if user-specific
|
|
98
|
-
- **Interactive widget** then components, styling; add client-router-and-streaming only if it streams
|
|
99
|
-
- **Protected area** then auth-and-sessions, routing-and-pages, testing
|
|
100
|
-
- **Instant-feeling mutation** then data-and-actions, optimistic-ui
|
|
101
|
-
|
|
102
|
-
## Default Workflow
|
|
103
|
-
|
|
104
|
-
1. **Classify the change.** Route contract, data model, server mutation, auth, or only UI?
|
|
105
|
-
2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `<form>` POST should already return correct HTML before any component hydrates.
|
|
106
|
-
3. **Put code in the narrowest owner.** Route-local first (`modules/<feature>/`), promote to `lib/` or `components/` only when reuse is real.
|
|
107
|
-
4. **Keep server-only code behind `.server.ts`** (Core WebJs Rules 1 and 2).
|
|
108
|
-
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing.
|
|
109
|
-
6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
|
|
110
|
-
7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
|
|
111
|
-
8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. Carry a row type into a shipping component with `import type` (erased before the browser sees it). Nothing enforces this (both spellings are valid TypeScript, so `webjs check` and `tsc` pass either way), which is why it is written down. See `references/typescript.md`.
|
|
112
|
-
9. **Test the narrowest meaningful layer**, and look at any UI change in a real browser (see Testing Defaults).
|
|
113
|
-
|
|
114
|
-
## Project Layout
|
|
115
|
-
|
|
116
|
-
```
|
|
117
|
-
app/ ROUTING ONLY (thin adapters importing from modules/)
|
|
118
|
-
layout.ts root layout (the ONLY file that may write <html>/<head>/<body>)
|
|
119
|
-
page.ts /
|
|
120
|
-
<segment>/page.ts /<segment>
|
|
121
|
-
[param]/page.ts dynamic route (params.param)
|
|
122
|
-
<path>/route.ts HTTP handler at /<path>
|
|
123
|
-
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
|
|
124
|
-
middleware.ts root middleware
|
|
125
|
-
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
|
|
126
|
-
components/ (custom elements), types.ts,
|
|
127
|
-
utils/ (pure; returns data, or an html fragment under utils/ui/)
|
|
128
|
-
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
|
|
129
|
-
lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
|
|
130
|
-
components/*.ts shared presentational custom elements (one per file)
|
|
131
|
-
db/*.server.ts Drizzle: schema, connection
|
|
132
|
-
public/* static assets, served at /public/<name>
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
App-internal imports use the `#` root alias (`import { db } from '#db/connection.server.ts'`), Node's native `package.json` imports field, not deep `../../../` relatives. A same-directory import stays relative.
|
|
136
|
-
|
|
137
|
-
## Core WebJs Rules (invariants)
|
|
138
|
-
|
|
139
|
-
1. Server-only code lives in `.server.ts`, `route.ts`, or `middleware.ts`. Never in a page, layout, or component (it crashes the browser at module load).
|
|
140
|
-
2. `'use server'` exports are `async` functions returning serializer-safe values, callable from browser code as RPC. Files without `'use server'` are server-only utilities: reach them only from `'use server'` actions, `route.ts`, or middleware. Never add `'use server'` to a file only other server code imports (the DB connection, the schema).
|
|
141
|
-
3. Custom element tag names contain a hyphen. Pass the tag to `Class.register('tag-name')`.
|
|
142
|
-
4. Event (`@`), property (`.`), and boolean (`?`) holes in `html` are UNQUOTED: `@click=${fn}`, never `@click="${fn}"`.
|
|
143
|
-
5. Signals are the default state primitive. Import `signal` / `computed` from `@webjsdev/core`, read via `signal.get()` inside `render()`. The base-class factory `WebComponent({ ... })` is only for values riding an HTML attribute or arriving via SSR hydration.
|
|
144
|
-
6. Page and layout default exports are functions returning a value; they never call `render()` themselves.
|
|
145
|
-
7. Light-DOM components with custom CSS prefix every class selector with their tag name. Prefer Tailwind (unique by construction).
|
|
146
|
-
8. Only the root layout may write `<!doctype>` / `<html>` / `<head>` / `<body>`.
|
|
147
|
-
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
|
|
148
|
-
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
|
|
149
|
-
11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
|
|
150
|
-
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>`. A bound submitter is SELF-SUFFICIENT (#1307): the renderer puts `formmethod="post"` and `formenctype` on the button itself, so it needs no bound form around it and works inside any form or none. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes (the identity IS the button's name/value pair, so both halves are spoken for), a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"` (a GET sends no body for the action to read), a BOUND submitter's own non-post `formmethod` or unparseable `formenctype`, and a non-action function all throw. A PLAIN button's own `formmethod` / `formenctype` is a legal native override and is left alone. A page has no `action` export, so a bare `<form method="post">` is a 405.
|
|
151
|
-
|
|
152
|
-
## Export Map
|
|
153
|
-
|
|
154
|
-
Find the right export fast. Load the linked reference for full examples.
|
|
155
|
-
|
|
156
|
-
### `@webjsdev/core` (browser + isomorphic)
|
|
157
|
-
|
|
158
|
-
- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `Class.register('tag')` binds the tag.
|
|
159
|
-
- `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
|
|
160
|
-
- `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
|
|
161
|
-
- `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
|
|
162
|
-
- `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
|
|
163
|
-
- `asset(path)` content-hashes a `public/` url so a deploy cannot serve stale bytes (`href=${asset('/public/app.css')}`), served `immutable` for a year. Page / layout / metadata route only, inside the render function. See `references/built-ins.md`.
|
|
164
|
-
- Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
|
|
165
|
-
- `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
|
|
166
|
-
- `@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.
|
|
167
|
-
|
|
168
|
-
### `@webjsdev/server` (server side)
|
|
169
|
-
|
|
170
|
-
- `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
|
|
171
|
-
- 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`.
|
|
172
|
-
- 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`.
|
|
173
|
-
- Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).
|
|
174
|
-
|
|
175
|
-
### File conventions
|
|
176
|
-
|
|
177
|
-
`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`), and the static app-root icon files (`icon.svg`, `apple-icon.png`, `manifest.webmanifest`, `favicon.ico`), auto-linked into `<head>`. The scaffold's `app/icon.svg` is a placeholder: replace it with the app's own icon.
|
|
178
|
-
|
|
179
|
-
## Canonical Patterns
|
|
180
|
-
|
|
181
|
-
### A page
|
|
182
|
-
|
|
183
|
-
```ts
|
|
184
|
-
// app/about/page.ts
|
|
185
|
-
import { html } from '@webjsdev/core';
|
|
186
|
-
export default function About() {
|
|
187
|
-
return html`<h1>About</h1>`;
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
### A dynamic route reading data through an action
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
// app/users/[id]/page.ts
|
|
195
|
-
import { html } from '@webjsdev/core';
|
|
196
|
-
import { getUser } from '#modules/users/queries/get-user.server.ts';
|
|
197
|
-
export default async function User({ params }: { params: { id: string } }) {
|
|
198
|
-
const user = await getUser(params.id); // never import the DB directly into a page
|
|
199
|
-
return html`<h1>${user.name}</h1>`;
|
|
200
|
-
}
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
### A server action
|
|
204
|
-
|
|
205
|
-
```ts
|
|
206
|
-
// modules/users/actions/update-profile.server.ts
|
|
207
|
-
'use server';
|
|
208
|
-
import { eq } from 'drizzle-orm';
|
|
209
|
-
import { db } from '#db/connection.server.ts';
|
|
210
|
-
import { users } from '#db/schema.server.ts';
|
|
211
|
-
export async function updateProfile(input: { id: string; name: string }) {
|
|
212
|
-
const name = String(input?.name || '').trim();
|
|
213
|
-
if (!name) return { success: false, error: 'name required', status: 400 };
|
|
214
|
-
const [row] = await db.update(users).set({ name }).where(eq(users.id, input.id)).returning();
|
|
215
|
-
return { success: true, data: row };
|
|
216
|
-
}
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
Call it from a component via a normal import (rewritten to a typed RPC stub). Never hand-write `fetch()`.
|
|
220
|
-
|
|
221
|
-
### An interactive component
|
|
222
|
-
|
|
223
|
-
```ts
|
|
224
|
-
// components/counter.ts
|
|
225
|
-
import { WebComponent, prop, html } from '@webjsdev/core';
|
|
226
|
-
class Counter extends WebComponent({ count: prop(Number) }) {
|
|
227
|
-
constructor() { super(); this.count = 0; }
|
|
228
|
-
render() {
|
|
229
|
-
return html`<button @click=${() => this.count++}>${this.count}</button>`;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
Counter.register('my-counter');
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
### The no-JS write path (a form-bound action)
|
|
236
|
-
|
|
237
|
-
```ts
|
|
238
|
-
// modules/contact/actions/send-message.server.ts
|
|
239
|
-
'use server';
|
|
240
|
-
export async function sendMessage(formData: FormData) {
|
|
241
|
-
const email = String(formData.get('email') || '');
|
|
242
|
-
if (!email) return { success: false, fieldErrors: { email: 'required' } };
|
|
243
|
-
return { success: true, redirect: '/thanks' };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// app/contact/page.ts
|
|
247
|
-
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
|
|
248
|
-
export default function Contact({ actionData }) {
|
|
249
|
-
return html`<form action=${sendMessage}><input name="email"></form>`;
|
|
250
|
-
}
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
Binding the action is the whole wiring: the renderer omits `action` (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` identity field. A form-bound action always receives the `FormData`.
|
|
254
|
-
|
|
255
|
-
Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place. A submission that binds nothing is a 405, and the submission is Origin-verified like an RPC call.
|
|
256
|
-
|
|
257
|
-
## Security And Session Defaults
|
|
258
|
-
|
|
259
|
-
- Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing.
|
|
260
|
-
- CSRF is an Origin / `Sec-Fetch-Site` check on both the action RPC and the form-submit path, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.
|
|
261
|
-
- Prod action errors are sanitized to a generic message plus a digest. Put a user-facing message on the `ActionResult` `{ success: false, error }` envelope, never on a raw throw.
|
|
262
|
-
- Use `forbidden()` for an authenticated user lacking permission, `unauthorized()` for an unauthenticated request. Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure instead of throwing.
|
|
263
|
-
- For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.
|
|
264
|
-
|
|
265
|
-
## Testing Defaults
|
|
266
|
-
|
|
267
|
-
- `npm run ci` before every push: it runs the `webjs.ci` step list in `package.json` (correctness, project health, types, a dependency audit, then the server, browser, and e2e test layers) with a result line per step, and CI runs the same list, so a green local run predicts the pipeline. `npm run ci -- --only Tests` runs one layer while iterating. See `references/testing.md` and `references/built-ins.md`.
|
|
268
|
-
- Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`.
|
|
269
|
-
- Add a browser test (`npm run test:browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.
|
|
270
|
-
- Render the app and LOOK for any UI change: `npm run check` and `npm run typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect.
|
|
271
|
-
- WebJs runs on Node 24+ AND Bun. Prove a runtime-sensitive change (serializer, listener, streams, `node:crypto`, the TS stripper) on both.
|
|
272
|
-
|
|
273
|
-
## Common Mistakes To Avoid
|
|
274
|
-
|
|
275
|
-
The invariants above are not repeated here; this list is the mistakes they do not already name.
|
|
276
|
-
|
|
277
|
-
- Promoting a whole page section to a component so that one control inside it can be interactive. The island should wrap the control and the state it reads. An oversized island ships its own JS AND un-elides every display-only component inside it, so the cost is not linear in what you moved.
|
|
278
|
-
- Writing `fetch()` to call your own server instead of importing the action.
|
|
279
|
-
- Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
|
|
280
|
-
- Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
|
|
281
|
-
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
282
|
-
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
|
|
283
|
-
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
|
|
284
|
-
- Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
|
|
285
|
-
- Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
|
|
286
|
-
- Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
|
package/resources/corpus.json
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
# Auth and Sessions
|
|
2
|
-
|
|
3
|
-
## What This Covers
|
|
4
|
-
|
|
5
|
-
- Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement
|
|
6
|
-
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing
|
|
7
|
-
- Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut`
|
|
8
|
-
- Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)`
|
|
9
|
-
- `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
|
|
10
|
-
- Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
|
|
11
|
-
- The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
|
|
12
|
-
- Requiring secrets from the environment and failing fast
|
|
13
|
-
- `cors()` with an explicit allowlist when `credentials: true`
|
|
14
|
-
|
|
15
|
-
Read this when a task touches login, logout, who-can-see-a-page, session
|
|
16
|
-
state, or the CSRF / CORS posture of a mutation. Sibling refs:
|
|
17
|
-
`data-and-actions.md` (the `ActionResult` envelope, validation, the RPC
|
|
18
|
-
boundary), `routing-and-pages.md` (the `forbidden.ts` / `unauthorized.ts`
|
|
19
|
-
boundary files and control-flow throws), `built-ins.md` (env vars, Redis
|
|
20
|
-
scaling, the full caching surface).
|
|
21
|
-
|
|
22
|
-
## Sessions
|
|
23
|
-
|
|
24
|
-
Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps.
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
// middleware.ts: enable on all routes. Storage is pluggable.
|
|
28
|
-
import { session, cookieSession, storeSession } from '@webjsdev/server';
|
|
29
|
-
export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() });
|
|
30
|
-
// cookieSession() -> whole session in a signed cookie (stateless, the default)
|
|
31
|
-
// storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
`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:
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
import { getSession } from '@webjsdev/server';
|
|
38
|
-
export async function GET(req: Request) {
|
|
39
|
-
const s = getSession(req);
|
|
40
|
-
s.set('userId', user.id); // write
|
|
41
|
-
const id = s.get('userId'); // read
|
|
42
|
-
s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read)
|
|
43
|
-
s.destroy(); // clear the whole session (logout)
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
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.
|
|
48
|
-
|
|
49
|
-
## Authentication (`createAuth`)
|
|
50
|
-
|
|
51
|
-
Configure providers once in a `.server.ts` file. `createAuth` returns
|
|
52
|
-
`auth` (read the session), `signIn` / `signOut` (the flows), and
|
|
53
|
-
`handlers` (the OAuth redirect endpoints).
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
// modules/auth/auth.server.ts
|
|
57
|
-
import { createAuth, Credentials, Google, GitHub } from '@webjsdev/server';
|
|
58
|
-
import { db } from '#db/connection.server.ts';
|
|
59
|
-
import { compare } from './password.server.ts';
|
|
60
|
-
|
|
61
|
-
export const { auth, signIn, signOut, handlers } = createAuth({
|
|
62
|
-
providers: [
|
|
63
|
-
Credentials({
|
|
64
|
-
async authorize(credentials) {
|
|
65
|
-
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
|
|
66
|
-
if (!user || !(await compare(credentials.password, user.passwordHash))) return null;
|
|
67
|
-
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
68
|
-
},
|
|
69
|
-
}),
|
|
70
|
-
Google(), // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
|
|
71
|
-
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
|
|
72
|
-
],
|
|
73
|
-
secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
|
|
74
|
-
pages: { error: '/login' }, // a failed sign-in 302s here with ?error=<code>
|
|
75
|
-
});
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
**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`:
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
// modules/auth/password.server.ts (a server-only utility, never reaches the browser)
|
|
82
|
-
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
83
|
-
import { promisify } from 'node:util';
|
|
84
|
-
const scryptAsync = promisify(scrypt);
|
|
85
|
-
export async function hash(pw: string) {
|
|
86
|
-
const salt = randomBytes(16).toString('hex');
|
|
87
|
-
return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
|
|
88
|
-
}
|
|
89
|
-
export async function compare(pw: string, stored: string) {
|
|
90
|
-
const [salt, key] = stored.split(':');
|
|
91
|
-
return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
|
|
92
|
-
}
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**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.
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
// app/api/auth/[...path]/route.ts
|
|
99
|
-
import { handlers } from '#modules/auth/auth.server.ts';
|
|
100
|
-
export const GET = handlers.GET;
|
|
101
|
-
export const POST = handlers.POST;
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
**Cookie names.** `createAuth` sets `webjs.auth` (session) and, for OAuth, `webjs.auth.state` and `webjs.auth.redirect` in development. In production (`NODE_ENV=production`) each carries the `__Host-` prefix (`__Host-webjs.auth`, ...), which a browser stores only when `Secure`, `Path=/` and without `Domain`, so an app on a sibling subdomain cannot toss its own session cookie into yours. Production reads only the prefixed names; a test or tool that hand-sets the cookie in production must use the prefixed name.
|
|
105
|
-
|
|
106
|
-
**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`:
|
|
107
|
-
|
|
108
|
-
```html
|
|
109
|
-
<form method="POST" action="/api/auth/signin/credentials">
|
|
110
|
-
<input type="hidden" name="redirectTo" value="/dashboard">
|
|
111
|
-
<input name="email" type="email" required><input name="password" type="password" required>
|
|
112
|
-
<button>Sign in</button>
|
|
113
|
-
</form>
|
|
114
|
-
<!-- log out -->
|
|
115
|
-
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
**OAuth sign-in returns the user where they started, with the same one form.** POST to `/api/auth/signin/github` (or `google`) with a hidden `redirectTo`, or link to `GET /api/auth/signin/github?redirectTo=/dashboard/x`; `signIn('github', undefined, { redirectTo })` does the same from an action. The target rides through the provider round trip in a short-lived signed cookie and the callback lands on it, so no wrapper around the auth route is needed:
|
|
119
|
-
|
|
120
|
-
```html
|
|
121
|
-
<form method="POST" action="/api/auth/signin/github">
|
|
122
|
-
<input type="hidden" name="redirectTo" value="/dashboard/x">
|
|
123
|
-
<button>Sign in with GitHub</button>
|
|
124
|
-
</form>
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
A `redirectTo` that arrives from a request (a form field or a query param, for OAuth or credentials) must be a same-origin local path: one leading `/`, not followed by `/` or `\`. An absolute URL, a protocol-relative `//host`, or a backslash variant is dropped (not repaired) and the sign-in lands on `/`, so the field is never an open redirect. A denied sign-in still goes to `pages.error`.
|
|
128
|
-
|
|
129
|
-
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).
|
|
130
|
-
|
|
131
|
-
**The header updates on its own after sign-in and sign-out.** A layout that reads `auth()` to render "Sign in" / "Sign out" is re-rendered after any mutating form submission the router handles (a form bound to a sign-in action, or a plain POST to `/api/auth/signin/credentials` / `/api/auth/signout`), and its markup is morphed in place, so there is nothing to refresh by hand (#1557). Only a sign-out done over RPC from a component (`await logout(); navigate('/')`) needs `await refreshPage('shell')` after it, since that navigation is a plain GET.
|
|
132
|
-
|
|
133
|
-
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
134
|
-
providers handle the full redirect flow. Read the session anywhere on the
|
|
135
|
-
server with `auth()`.
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
// in a page or action
|
|
139
|
-
import { redirect } from '@webjsdev/core';
|
|
140
|
-
const session = await auth();
|
|
141
|
-
if (!session) throw redirect('/login'); // page-render gate: 302
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
`auth()` resolves `{ user }`, where `user` is `Record<string, unknown>` by
|
|
145
|
-
default. To read custom fields (`session.user.id`, `session.user.role`)
|
|
146
|
-
without a cast, type the session by augmenting the `AuthUser` interface
|
|
147
|
-
(types-only, opt-in). See `typescript.md`.
|
|
148
|
-
|
|
149
|
-
## Required secrets, fail fast
|
|
150
|
-
|
|
151
|
-
| Variable | Effect |
|
|
152
|
-
|---|---|
|
|
153
|
-
| `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
|
|
154
|
-
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | Google OAuth (optional) |
|
|
155
|
-
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | GitHub OAuth (optional) |
|
|
156
|
-
| `SESSION_SECRET` | Cookie session signing |
|
|
157
|
-
| `REDIS_URL` | When set, sessions and rate limit and cache use Redis |
|
|
158
|
-
|
|
159
|
-
Never ship a demo secret or an in-source default. Read each secret from
|
|
160
|
-
`process.env` and fail boot when a required one is absent (use the
|
|
161
|
-
optional `env.ts` schema file to validate at boot and name every bad var).
|
|
162
|
-
A guessable signing secret means forgeable sessions.
|
|
163
|
-
|
|
164
|
-
## Protecting a route
|
|
165
|
-
|
|
166
|
-
Gate at the top of the page or layout that owns the protected subtree.
|
|
167
|
-
Because a page runs only on the server, the check happens before any HTML
|
|
168
|
-
is produced, so a logged-out visitor never receives protected markup.
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
// app/dashboard/page.ts
|
|
172
|
-
import { html, redirect } from '@webjsdev/core';
|
|
173
|
-
import { auth } from '#modules/auth/auth.server.ts';
|
|
174
|
-
|
|
175
|
-
export default async function Dashboard() {
|
|
176
|
-
const session = await auth();
|
|
177
|
-
if (!session) throw redirect('/login'); // not signed in
|
|
178
|
-
return html`<h1>Welcome, ${session.user.name}</h1>`;
|
|
179
|
-
}
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Reading the session through `auth()` also auto-excludes the page from the
|
|
183
|
-
server HTML response cache, so a per-user page is never cached and served
|
|
184
|
-
to another visitor (see `built-ins.md`).
|
|
185
|
-
|
|
186
|
-
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:
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
// app/dashboard/middleware.ts (protects /dashboard/*)
|
|
190
|
-
import { auth } from '#modules/auth/auth.server.ts';
|
|
191
|
-
export default async function requireAuth(req: Request, next: () => Promise<Response>) {
|
|
192
|
-
const session = await auth(req);
|
|
193
|
-
if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } });
|
|
194
|
-
return next();
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
`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.
|
|
199
|
-
|
|
200
|
-
## `forbidden()` (403) vs `unauthorized()` (401)
|
|
201
|
-
|
|
202
|
-
Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`
|
|
203
|
-
model. Both render the NEAREST matching boundary (innermost wins), else a
|
|
204
|
-
default page.
|
|
205
|
-
|
|
206
|
-
- `unauthorized()` for a request that is NOT authenticated (no valid
|
|
207
|
-
session). Renders the nearest `unauthorized.ts`. Use it when the fix is
|
|
208
|
-
"log in".
|
|
209
|
-
- `forbidden()` for an authenticated user who LACKS permission for this
|
|
210
|
-
resource. Renders the nearest `forbidden.ts`. Use it when logging in as
|
|
211
|
-
a different account would not help.
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
// app/admin/page.ts
|
|
215
|
-
import { html, forbidden, unauthorized } from '@webjsdev/core';
|
|
216
|
-
import { auth } from '#modules/auth/auth.server.ts';
|
|
217
|
-
|
|
218
|
-
export default async function Admin() {
|
|
219
|
-
const session = await auth();
|
|
220
|
-
if (!session) throw unauthorized(); // 401, renders unauthorized.ts
|
|
221
|
-
if (session.user.role !== 'admin') throw forbidden(); // 403, renders forbidden.ts
|
|
222
|
-
return html`<h1>Admin</h1>`;
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
Both work from a page or layout render AND from a form-bound action (the no-JS
|
|
227
|
-
write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`,
|
|
228
|
-
or nested variants) live alongside the routes they cover and each
|
|
229
|
-
default-export a function returning the boundary markup.
|
|
230
|
-
|
|
231
|
-
`forbidden()` / `unauthorized()` are for a page / layout render or a page
|
|
232
|
-
`action`. They are NOT for a `route.ts` handler (return a `Response` with
|
|
233
|
-
the status there), and NOT for a `'use server'` RPC action (a raw throw
|
|
234
|
-
becomes a generic 500). See the next section for the action case.
|
|
235
|
-
|
|
236
|
-
## Auth failure inside a `'use server'` action: return, do not throw
|
|
237
|
-
|
|
238
|
-
A `'use server'` RPC action must NOT throw `forbidden()` / `unauthorized()`
|
|
239
|
-
/ `redirect()` for an auth failure. A raw throw there is sanitized to a
|
|
240
|
-
generic 500 in production, so the caller loses the reason. Instead return
|
|
241
|
-
an `ActionResult` failure envelope with a status the client can act on.
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
// modules/posts/actions/delete-post.server.ts
|
|
245
|
-
'use server';
|
|
246
|
-
import { eq } from 'drizzle-orm';
|
|
247
|
-
import { auth } from '#modules/auth/auth.server.ts';
|
|
248
|
-
import { db } from '#db/connection.server.ts';
|
|
249
|
-
import { posts } from '#db/schema.server.ts';
|
|
250
|
-
|
|
251
|
-
export async function deletePost(input: { id: string }) {
|
|
252
|
-
const session = await auth();
|
|
253
|
-
if (!session) return { success: false, error: 'Sign in to continue.', status: 401 };
|
|
254
|
-
const post = await db.query.posts.findFirst({ where: { id: input.id } });
|
|
255
|
-
if (post?.authorId !== session.user.id) {
|
|
256
|
-
return { success: false, error: 'Not your post.', status: 403 };
|
|
257
|
-
}
|
|
258
|
-
await db.delete(posts).where(eq(posts.id, input.id));
|
|
259
|
-
return { success: true };
|
|
260
|
-
}
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
The user-facing message belongs on the envelope's `error` field, never on
|
|
264
|
-
a raw throw (prod strips a thrown message to a digest). See
|
|
265
|
-
`data-and-actions.md` for the full `ActionResult` shape.
|
|
266
|
-
|
|
267
|
-
## CSRF: an Origin / `Sec-Fetch-Site` check, not a token cookie
|
|
268
|
-
|
|
269
|
-
Action RPC CSRF protection is an Origin / `Sec-Fetch-Site` header check
|
|
270
|
-
(the Remix 3 / Go 1.25 model), NOT a token cookie. A state-changing verb
|
|
271
|
-
(POST / PUT / PATCH / DELETE) passes only when:
|
|
272
|
-
|
|
273
|
-
- `Sec-Fetch-Site` is `same-origin` or `none`, OR
|
|
274
|
-
- (older browsers with no `Sec-Fetch-Site`) the `Origin` host matches the
|
|
275
|
-
request host, OR
|
|
276
|
-
- the source is listed in `webjs.allowedOrigins`.
|
|
277
|
-
|
|
278
|
-
Otherwise the request is a 403. A safe GET action is CSRF-exempt. Because
|
|
279
|
-
there is no CSRF token cookie and no `Set-Cookie` rides the SSR HTML, a
|
|
280
|
-
page that opts into a public `Cache-Control` stays CDN-edge-cacheable.
|
|
281
|
-
|
|
282
|
-
A `route.ts` REST endpoint is NOT covered. Authenticate every mutating
|
|
283
|
-
endpoint yourself, run `export const validate`, log without secrets, and
|
|
284
|
-
rate-limit.
|
|
285
|
-
|
|
286
|
-
## CORS: explicit allowlist when `credentials: true`
|
|
287
|
-
|
|
288
|
-
For cross-origin requests use `cors()` from `@webjsdev/server` as
|
|
289
|
-
middleware. When `credentials: true` you MUST pass an explicit origin
|
|
290
|
-
allowlist. Never `'*'` with credentials (that would expose an
|
|
291
|
-
authenticated response to any origin).
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
// middleware.ts
|
|
295
|
-
import { cors } from '@webjsdev/server';
|
|
296
|
-
export default cors({
|
|
297
|
-
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
298
|
-
credentials: true, // requires the explicit allowlist above
|
|
299
|
-
});
|
|
300
|
-
```
|