fastify-txstate 3.6.9 → 4.0.1

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 CHANGED
@@ -1,9 +1,11 @@
1
1
  # fastify-txstate
2
- A small wrapper for fastify providing a set of common conventions & utility functions we use.
2
+ A small wrapper for fastify providing a set of common conventions & utility functions for presenting an HTTP server.
3
+
4
+ > **v4 upgrades to fastify 5 and drops CommonJS, among other things.** See the [changelog](https://github.com/txstate-etc/fastify-txstate/blob/master/CHANGELOG.md) for upgrade notes.
3
5
 
4
6
  # Basic Usage
5
7
  ```javascript
6
- const Server = require('fastify-txstate').default
8
+ import Server from 'fastify-txstate'
7
9
  const server = new Server()
8
10
  server.app.get('/yourpath', async (req, res) => {
9
11
  return { hello: 'world' }
@@ -19,7 +21,7 @@ Some resources are available to make error handling easy.
19
21
  ## HttpError
20
22
  This class is available to throw simple errors while processing a request:
21
23
  ```javascript
22
- const { HttpError } = require('fastify-txstate')
24
+ import { HttpError } from 'fastify-txstate'
23
25
  server.app.get('/yourpath', async (req, res) => {
24
26
  if (!req.params.id) throw new HttpError(400, 'Please provide an id.')
25
27
  /* ... */
@@ -56,6 +58,65 @@ This format is well supported by our @txstate-mws/svelte-forms library, so it sh
56
58
  ### ValidationError
57
59
  `ValidationErrors` is preferred since it will show multiple errors at once, instead of making the user fix errors one at a time and not know how far they are from being done. If you just need to throw a quick single error, `throw new ValidationError('Wrong!', 'answer')` is also available.
58
60
 
61
+ # Route Schemas
62
+ We configure the `@fastify/type-provider-json-schema-to-ts` type provider, so when you define a schema with `as const`, TypeScript infers request and response types automatically:
63
+ ```javascript
64
+ const createUserBody = {
65
+ type: 'object',
66
+ properties: {
67
+ name: { type: 'string' },
68
+ email: { type: 'string' },
69
+ age: { type: 'integer', minimum: 0 }
70
+ },
71
+ additionalProperties: false
72
+ } as const
73
+
74
+ server.app.post('/users', {
75
+ schema: {
76
+ body: createUserBody,
77
+ response: { 200: validatedResponse, 422: validatedResponse }
78
+ }
79
+ }, async (req, res) => {
80
+ // req.body is fully typed — name, email, age are all inferred
81
+ })
82
+ ```
83
+
84
+ When a route has a `body` schema, we automatically add 400 and 422 response schemas for validation errors, so those don't need to be specified manually.
85
+
86
+ ## Schema Validation vs. Business Validation
87
+ We configure Ajv with `coerceTypes`, `allErrors`, `ajv-formats` (for `date-time`, `email`, `uri`, etc.), and `ajv-errors` (for custom `errorMessage` strings). `strictSchema` is off, so OpenAPI properties like `example` and `description` won't cause errors.
88
+
89
+ An important design point: **schema validation is for catching client bugs, not user mistakes.** When the schema rejects a request, the user gets a generic 400 — not a friendly inline message on a form field. If you want the user to see "Name is required" next to the name input, don't put `required: ['name']` in the schema. Instead, make it optional in the schema and check it in your route handler with a `ValidationMessage` (see [Error Handling](#error-handling)). Reserve schema-level `required`, `pattern`, and `format` for things the client is responsible for, like ensuring dates are in ISO format.
90
+
91
+ ## SchemaObject Type
92
+ If you define schemas as standalone objects, you can take advantage of the `SchemaObject` exported by `@txstate-mws/fastify-shared`. It extends JSON Schema with OpenAPI properties (`example`, `description`) and `ajv-errors` support (`errorMessage`). Add `as const satisfies SchemaObject` to the end of your object before you start filling it with properties and you'll
93
+ get autocomplete support in your IDE and confidence that your schema is compliant.
94
+ ```typescript
95
+ import type { SchemaObject } from '@txstate-mws/fastify-shared'
96
+
97
+ const addressSchema = {
98
+ type: 'object',
99
+ properties: {
100
+ street: { type: 'string', example: '123 Main St' },
101
+ city: { type: 'string', example: 'San Marcos' },
102
+ zip: { type: 'string', description: '5-digit US zip code' }
103
+ },
104
+ additionalProperties: false
105
+ } as const satisfies SchemaObject
106
+ ```
107
+
108
+ ## Response Serialization
109
+ Responses are validated through Ajv (not fast-json-stringify), so input and output validation behave identically. If a response doesn't match its schema, the server throws an error rather than sending malformed data. Null values are converted to `undefined` before validation, and Date objects are automatically stringified to ISO format.
110
+
111
+ ## Swagger / OpenAPI
112
+ Call `server.swagger()` before registering routes to enable auto-generated documentation:
113
+ ```javascript
114
+ const server = new Server()
115
+ await server.swagger({ path: '/docs' })
116
+ // register routes after this
117
+ ```
118
+ Route schemas are exposed in the OpenAPI spec automatically. The `example` and `description` properties appear in the Swagger UI. If authentication is configured, security schemes are added automatically.
119
+
59
120
  ## Custom Error Handling
60
121
  If you would like special treatment for certain errors, `addErrorHandler` provides an easy way:
61
122
  ```javascript
@@ -65,7 +126,7 @@ server.addErrorHandler(async (err, req, res) => {
65
126
  }
66
127
  })
67
128
  ```
68
- In this case we only need custom error handling for a specific class of Error. Calling `res.send()` in your handler is how you signal that the error has been intercepted. If you do not call `res.send()`, the default error handling will kick in, so you can still throw `HttpError` or `FailedValidationError` and have them handled properly.
129
+ In this case we only need custom error handling for a specific class of Error. Calling `res.send()` in your handler is how you signal that the error has been intercepted. If you do not call `res.send()`, the default error handling will kick in, so you can still throw `HttpError` or `ValidationErrors` and have them handled properly.
69
130
 
70
131
  You may call `addErrorHandler` multiple times; they will be executed in order and bail out when one calls `res.send()`.
71
132
  ## Opt-Out of Error Handling
@@ -90,9 +151,18 @@ During this period, `/health` will return HTTP 503, but all other requests will
90
151
  # Origin Checking
91
152
  To help prevent XSRF attacks, we automatically reject requests that send an origin header that doesn't match the host (sub)domain. Only domain is compared, not protocol or port. This is especially helpful in large organizations where untrusted web sites run under different subdomains. SameSite cookies can help with attacks from other domains, but attacks on the same subdomain can still succeed.
92
153
 
93
- You can authorize more subdomains with the `validOriginHosts` configuration option, or by setting the `VALID_ORIGIN_HOSTS` environment variable. You can authorize subdomains at runtime with `server.setValidOriginHosts(hosts: string[])`.
154
+ There are several ways to allow additional origins. Each is available as a constructor config option and an environment variable (comma-separated).
94
155
 
95
- You can disable these origin checks entirely with the `skipOriginCheck` configuration or `SKIP_ORIGIN_CHECK` environment variable.
156
+ | Config | Env Var | Match behavior |
157
+ |--------|---------|----------------|
158
+ | `validOrigins` | `VALID_ORIGINS` | Exact origin match including scheme and port (e.g. `https://app.example.com`) |
159
+ | `validOriginHosts` | `VALID_ORIGIN_HOSTS` | Hostname match, ignoring scheme and port (e.g. `app.example.com`) |
160
+ | `validOriginSuffixes` | `VALID_ORIGIN_SUFFIXES` | Domain suffix match — allows any subdomain (e.g. `example.com` allows `foo.example.com`, `bar.baz.example.com`) |
161
+ | `checkOrigin` | — | Custom function `(req) => boolean` for arbitrary logic. Runs after the other checks; return true to allow. |
162
+
163
+ All methods are additive — an origin is allowed if it passes any check.
164
+
165
+ You can disable origin checks entirely with the `skipOriginCheck` configuration or `SKIP_ORIGIN_CHECK` environment variable.
96
166
 
97
167
  # Reverse Proxy
98
168
  If your application is behind a reverse proxy, you'll want to set the `trustProxy` configuration to true so that variables like `request.protocol` get set correctly. You can also set the `TRUST_PROXY` environment variable. `true` or `1` will translate to `{ trustProxy: true }`; anything else will be passed unchanged as a string.
@@ -102,6 +172,321 @@ We try to set up logging well by default, including things like the HTTP tracepa
102
172
 
103
173
  Development and production logs are different, based on the `NODE_ENV` environment variable. The development logger is designed to be extremely brief and not in JSON format, so that you can see errors clearly.
104
174
 
105
- If you want to manipulate the logging you can import the `devLogger` and `prodLogger` into your project, manipulate them, and pass them into the server constructor configuration.
175
+ If you want to provide your own logger, you can pass a pino instance via the `loggerInstance` option in the server constructor configuration. The `devLogger` and `prodLogger` are also exported if you'd like to use them as a starting point.
106
176
 
107
177
  You can also simply add information to the `reply.extraLogInfo` object and it will automatically appear in the outgoing access log in production.
178
+
179
+ # Authentication
180
+ Pass an `authenticate` function to the Server constructor to enable authentication. It runs as an `onRequest` hook on all standard HTTP methods (GET, POST, PUT, PATCH, DELETE), except `/health` and swagger endpoints. The return value is available at `req.auth` in your route handlers and is included in production logs (minus `token`, `accessToken`, and `issuerConfig`).
181
+ ```javascript
182
+ import Server from 'fastify-txstate'
183
+ const server = new Server({
184
+ authenticate: async (req) => {
185
+ // extract and verify credentials from the request
186
+ // return a FastifyTxStateAuthInfo object, or undefined if unauthenticated
187
+ // throw to reject with 401
188
+ }
189
+ })
190
+ ```
191
+ The `authenticate` function should return a `FastifyTxStateAuthInfo` object with at least `username`, `sessionId`, and `token`. This gives us a predictable interface, since raw JWT claims may vary by provider. Returning `undefined` means the request is unauthenticated (but allowed). Throwing an error sends a 401 response.
192
+
193
+ We provide a built-in `jwtAuthenticate` that validates JWT tokens from any combination of issuer types — OAuth/OIDC providers (with auto-discovery), TxState Unified Auth, raw JWKS endpoints, symmetric secrets, and asymmetric public keys. You can also write your own `authenticate` for any authentication scheme — API keys, session lookups, custom token formats, etc.
194
+
195
+ # JWT Authentication
196
+ The `jwtAuthenticate` function validates JWTs from the `Authorization: Bearer` header or a session cookie. It supports any mix of these issuer types:
197
+
198
+ - **OAuth/OIDC** — auto-discovers the provider's JWKS via `.well-known/openid-configuration` or `.well-known/oauth-authorization-server` from the issuer's `iss` claim.
199
+ - **TxState Unified Auth** — JWKS + a `/validateToken` poll for centralized deauth.
200
+ - **JWKS endpoint** — a direct JWKS URL (no discovery).
201
+ - **Asymmetric public key** — a PEM-encoded RSA/EC public key.
202
+ - **Symmetric secret** — an HMAC shared secret.
203
+
204
+ For providers like Google that issue opaque access tokens, have the client send the ID token instead — it's a standard JWT that proves the user's identity without requiring a round-trip to the provider on every request.
205
+ ```javascript
206
+ import Server, { jwtAuthenticate } from 'fastify-txstate'
207
+ const server = new Server({
208
+ authenticate: jwtAuthenticate({ authenticateAll: true })
209
+ })
210
+ ```
211
+ ## Environment Variables
212
+ At least one issuer must be configured. Use any combination of the env-var shortcuts below, or the JSON-based `JWT_TRUSTED_ISSUERS` for full control. Configuration from both sources is merged.
213
+
214
+ | Variable | Description |
215
+ |----------|-------------|
216
+ | `UA_URL` | URL of a TxState Unified Auth service. Creates a unified-auth issuer with `iss: 'unified-auth'`. |
217
+ | `UA_URL_INTERNAL` | Internal URL of the UA service for server-to-server requests in split-horizon DNS scenarios. |
218
+ | `OAUTH_URLS` | Comma-separated OAuth/OIDC issuer URLs (e.g. `https://accounts.google.com,https://login.microsoftonline.com/{tenant}/v2.0`). Each becomes an issuer with `iss` equal to the URL. |
219
+ | `OAUTH_INTERNAL_URLS` | Map external OAuth issuer URLs to internal URLs for docker-compose / split-horizon DNS. Format: `external=internal,external=internal` (e.g. `https://auth.example.com=http://keycloak:8080`). Rewrites server-to-server requests (discovery, JWKS, token exchange) but not browser redirects. |
220
+ | `JWT_SECRET` | Symmetric HMAC secret for verifying JWTs. Tokens must have `iss: 'jwt-secret'`. |
221
+ | `JWT_PUBLIC_KEY` | PEM-encoded asymmetric public key for verifying JWTs. Tokens must have `iss: 'jwt-public-key'` (use `JWT_TRUSTED_ISSUERS` instead for another name). Literal `\n` is converted to real newlines so PEMs survive env-var encoding. |
222
+ | `JWT_TRUSTED_AUDIENCES` | Comma-separated list of accepted `aud` values, unioned into every issuer's audience list. See [Audience Validation](#audience-validation). |
223
+ | `JWT_TRUSTED_CLIENTIDS` | Comma-separated list of accepted `client_id` values, unioned into every issuer's client-id list. |
224
+ | `JWT_TRUSTED_ISSUERS` | JSON array of issuer configs for advanced setups. See [Issuer JSON Config](#issuer-json-config). |
225
+
226
+ ### Issuer JSON Config
227
+ `JWT_TRUSTED_ISSUERS` accepts a JSON array of objects. Each object describes one issuer:
228
+
229
+ | Field | Required | Description |
230
+ |-------|----------|-------------|
231
+ | `iss` | Yes | The `iss` claim that tokens from this issuer must carry. |
232
+ | `type` | No | One of `oauth`, `jwks`, `unified-auth`, `publicKey`, `secret`. Inferred from other fields if omitted: `iss === 'unified-auth'` → unified-auth; `secret` → secret; `publicKey` → publicKey; `url` → jwks. Set explicitly to `oauth` to enable `.well-known` discovery. |
233
+ | `url` | Conditional | Required for `oauth`, `jwks`, and `unified-auth`. For `oauth` it's the issuer URL (discovery is performed relative to it); for `jwks` it's the JWKS endpoint directly. |
234
+ | `publicKey` | Conditional | PEM-encoded public key (`publicKey` type). |
235
+ | `secret` | Conditional | Symmetric HMAC secret (`secret` type). |
236
+ | `internalUrl` | No | Server-to-server URL prefix override for split-horizon DNS. |
237
+ | `audiences` | No | Array of accepted `aud` values for this issuer. Unioned with `JWT_TRUSTED_AUDIENCES`. |
238
+ | `clientIds` | No | Array of accepted `client_id` values for this issuer. Unioned with `JWT_TRUSTED_CLIENTIDS`. |
239
+ | `validateUrl` | No | (unified-auth only) Override URL for the deauth poll. Resolved relative to `url`. Defaults to `<url>/validateToken`. |
240
+ | `logoutUrl` | No | End-session URL surfaced as `req.auth.issuerConfig.logoutUrl`. For OAuth issuers this is auto-discovered; set only to override. Resolved relative to `url`. |
241
+
242
+ ## Options
243
+ | Option | Description |
244
+ |--------|-------------|
245
+ | `authenticateAll` | If true, all requests require authentication except routes in `exceptRoutes` or `optionalRoutes`. |
246
+ | `exceptRoutes` | `Set<string>` of route URLs that skip authentication entirely and do not receive an auth object. |
247
+ | `optionalRoutes` | `Set<string>` of route URLs that do not require authentication but populate `req.auth` if a session is available. |
248
+ | `extraClaims` | A function that receives the full JWT payload and returns extra properties to merge into the auth object (e.g. `payload => ({ roles: payload.roles })`). If you use this, you should also set `JWT_TRUSTED_AUDIENCES` or per-issuer `audiences`. See [Audience Validation](#audience-validation). |
249
+
250
+ Calling `registerOAuthCookieRoutes` or `registerUaCookieRoutes` automatically excludes their callback/redirect routes from authentication and marks their logout routes as optional, so you do not need to list them here.
251
+
252
+ ## Cookie Endpoints
253
+ For server-rendered applications or SPAs that need cookie-based sessions, `registerOAuthCookieRoutes` implements the full OAuth authorization code flow with PKCE (S256), storing the ID token in an HttpOnly cookie. The access token and refresh token are stored in separate cookies (optionally encrypted via `OAUTH_COOKIE_SECRET`). Expired ID tokens are transparently refreshed using the refresh token cookie.
254
+ ```javascript
255
+ import Server, { jwtAuthenticate, registerOAuthCookieRoutes } from 'fastify-txstate'
256
+ const server = new Server({
257
+ authenticate: jwtAuthenticate({ authenticateAll: true })
258
+ })
259
+ registerOAuthCookieRoutes(server.app)
260
+ ```
261
+ The access token is available at `req.auth.accessToken` for making requests to the provider's APIs on behalf of the user (e.g. Google Drive, Microsoft Graph).
262
+ `registerOAuthCookieRoutes` accepts an optional second argument with:
263
+ | Option | Description |
264
+ |--------|-------------|
265
+ | `scopes` | Array of scopes to always include in the authorization request, merged with any scopes the client passes via the `scope` query parameter. |
266
+ | `loginPage` | A function for rendering a login selection page when multiple issuers are configured. See [Multiple Issuers](#multiple-issuers). |
267
+
268
+ ### Multiple Issuers
269
+ When multiple OAuth issuers are configured (via `OAUTH_URLS` or `JWT_TRUSTED_ISSUERS`), you can provide a `loginPage` function to let the user choose which provider to sign in with. The function receives an array of `{ issuerUrl, redirectHref }` and should return an HTML string.
270
+ ```javascript
271
+ registerOAuthCookieRoutes(server.app, {
272
+ loginPage: issuers => `<!DOCTYPE html>
273
+ <html><body>
274
+ <h1>Sign in with</h1>
275
+ ${issuers.map(i => `<a href="${i.redirectHref}">${new URL(i.issuerUrl).hostname}</a>`).join('<br>')}
276
+ </body></html>`
277
+ })
278
+ ```
279
+ When a user hits `/.oauthRedirect` without specifying an `issuer` query parameter, they see this page. Each link redirects back to `/.oauthRedirect` with the chosen issuer pre-filled. If no `loginPage` is provided, the first trusted issuer is used. Clients can also bypass the selection by passing `issuer` directly: `/.oauthRedirect?requestedUrl=...&issuer=https://accounts.google.com`.
280
+
281
+ ### Additional Environment Variables
282
+ | Variable | Required | Description |
283
+ |----------|----------|-------------|
284
+ | `OAUTH_COOKIE_CLIENT_ID` | Yes | OAuth client ID for the authorization code flow. |
285
+ | `OAUTH_COOKIE_CLIENT_SECRET` | No | OAuth client secret. PKCE secures the code exchange, but some providers require a secret even with PKCE. |
286
+ | `OAUTH_COOKIE_SECRET` | No | If set, the refresh token and access token cookies are encrypted with AES-256-GCM. If not, they are stored as plaintext (still HttpOnly and Secure). |
287
+ | `OAUTH_COOKIE_NAME` | No | Name for the session cookie. Defaults to a random hex string. |
288
+ | `PUBLIC_URL` | No | Base URL for the API, used to generate callback URIs (e.g. `https://myapp.example.com/api`). Derived from the request hostname if not set. |
289
+ | `UI_URL` | No | Base URL for the UI (e.g. `https://myapp.example.com`). Used as the default redirect destination after login/logout. If not set, guessed by removing the last path segment from `PUBLIC_URL` or the request URL. |
290
+
291
+ ### Routes
292
+ - **`GET /.oauthRedirect?requestedUrl=...&scope=...&issuer=...`** — Redirects to the OAuth provider's login page. `requestedUrl` is required and specifies where to redirect after login. `scope` is optional and defaults to `openid offline_access`. `issuer` is optional and selects which trusted issuer to use; if omitted with multiple issuers and a `loginPage` configured, a selection page is shown.
293
+ - **`GET /.oauthCallback`** — Handles the provider's redirect. Exchanges the authorization code for tokens (using PKCE), sets the ID token, access token, and refresh token as cookies, and redirects to the original `requestedUrl`. If no ID token is returned, falls back to the access token if it is a JWT.
294
+ - **`GET /.oauthLogout`** — Clears all OAuth cookies and redirects to the provider's `end_session_endpoint` if available, with the ID token as a hint for single sign-out.
295
+
296
+ ### Server-Rendered Login Redirects
297
+ For server-rendered routes where you want to redirect unauthenticated users straight into the login flow (rather than returning a 401 and letting client code react), call `requireCookieAuthOAuth(req, res)` at the top of your handler. If `req.auth` is empty it redirects the browser through `/.oauthRedirect` (preserving the current URL as `requestedUrl`) and returns `true`; otherwise it returns `false` and your handler proceeds.
298
+ ```javascript
299
+ import { requireCookieAuthOAuth } from 'fastify-txstate'
300
+ server.app.get('/dashboard', async (req, res) => {
301
+ if (await requireCookieAuthOAuth(req, res)) return
302
+ // ...render the page using req.auth
303
+ })
304
+ ```
305
+ If you are using `registerUaCookieRoutes` for TxState Unified Auth instead of OAuth, the equivalent helper is `requireCookieAuthUa(req, res)`. (The old name `requireCookieAuth` still works but is deprecated.)
306
+
307
+ ## Client-Side Authentication (without cookie endpoints)
308
+ If you are implementing the OAuth flow in your client application instead of using the cookie endpoints above, send the token to the API as an `Authorization: Bearer <token>` header. Here is what you need to know:
309
+
310
+ ### Which token to send
311
+ The API validates tokens locally by verifying the JWT signature against the provider's JWKS. This means **the token must be a JWT**. Choose accordingly:
312
+ - **Microsoft, Okta, Auth0, Keycloak**: Access tokens are JWTs. Send the access token.
313
+ - **Google**: Access tokens are opaque and cannot be verified locally. Send the **ID token** instead — it is a standard JWT containing the user's identity.
314
+ - **General rule**: If your provider's access token is a JWT (three base64url segments separated by dots), send it. If it's opaque, send the ID token.
315
+
316
+ ### Scopes
317
+ Scopes control what the user sees on the consent screen and what permissions the token carries. Each provider has its own conventions:
318
+ - **Google**: `openid` for basic sign-in, add `email` or `profile` for more claims. Scopes like `https://www.googleapis.com/auth/drive.readonly` authorize access to Google APIs — only request these if the client needs them.
319
+ - **Microsoft**: `openid` for sign-in, `User.Read` for Microsoft Graph, `api://{resource-id}/.default` for custom APIs.
320
+ - **Okta / Auth0**: `openid` for sign-in, custom scopes as configured in your authorization server.
321
+ - For all providers, `openid` is the minimum needed to get an ID token.
322
+
323
+ ### Refresh tokens
324
+ The client is responsible for refreshing tokens before they expire and sending a fresh token with each request. How to obtain a refresh token varies by provider:
325
+ - **Most OIDC providers** (Microsoft, Okta, Auth0, Keycloak): Request the `offline_access` scope.
326
+ - **Google**: Pass `access_type=offline&prompt=consent` as query parameters on the authorization request. The `offline_access` scope is ignored.
327
+ - **Apple, AWS Cognito**: Return refresh tokens automatically based on app configuration — no special scope needed.
328
+
329
+ ### PKCE
330
+ Use PKCE (S256) for the authorization code exchange even if your provider doesn't require it. Generate a `code_verifier`, send the `code_challenge` in the authorization request, and include the `code_verifier` when exchanging the code for tokens. This protects against authorization code interception and is supported by all major providers.
331
+
332
+ # Streaming File Proxy with postFormData
333
+ When your API receives a file upload and needs to forward it to another service, you typically have to buffer the entire file in memory or write it to disk first. The `postFormData` helper avoids this by constructing a multipart/form-data request from streams, allowing you to pipe an incoming upload directly to a remote API with no intermediate storage.
334
+
335
+ For example, proxying an uploaded file to S3-compatible storage:
336
+ ```javascript
337
+ import Server, { postFormData } from 'fastify-txstate'
338
+ const server = new Server()
339
+ server.app.post('/upload', async (req, res) => {
340
+ const results = []
341
+ for await (const part of req.parts()) {
342
+ if (part.type === 'file') {
343
+ // forward each file stream directly to S3 with no intermediate storage
344
+ const resp = await postFormData(
345
+ `https://s3.amazonaws.com/${BUCKET_NAME}`,
346
+ [
347
+ { name: 'key', value: `uploads/${part.filename}` },
348
+ { name: 'Content-Type', value: part.mimetype },
349
+ { name: 'file', value: part.file, filename: part.filename, filetype: part.mimetype }
350
+ ],
351
+ { Authorization: `AWS ${AWS_ACCESS_KEY}:${signature}` }
352
+ )
353
+ results.push({ filename: part.filename, status: resp.status })
354
+ }
355
+ }
356
+ return results
357
+ })
358
+ ```
359
+
360
+ Each field is either a text field (`{ name, value: string }`) or a file field (`{ name, value: ReadableStream | Readable, filename?, filetype?, filesize? }`). If all file fields include `filesize`, a `Content-Length` header is calculated automatically; otherwise the request is sent as chunked.
361
+
362
+ You can also pass custom headers as a third argument: `postFormData(url, fields, { Authorization: 'Bearer ...' })`.
363
+
364
+ # File Storage with FileSystemHandler
365
+ `FileSystemHandler` provides an opinionated way to stream uploaded files into the local filesystem, named by their SHA-256 checksum. Since identical files produce the same checksum, duplicates are automatically deduplicated — uploading the same file twice stores it only once.
366
+
367
+ Files are organized into a two-level directory structure based on the checksum (`a/b/cdef...`) to avoid overwhelming a single directory with too many entries.
368
+
369
+ ```javascript
370
+ import Server, { FileSystemHandler } from 'fastify-txstate'
371
+ const storage = new FileSystemHandler({ tmpdir: '/files/tmp', permdir: '/files/storage' })
372
+ await storage.init() // ensures tmpdir and permdir exist
373
+
374
+ const server = new Server()
375
+ server.app.post('/upload', async (req, res) => {
376
+ const results = []
377
+ for await (const part of req.parts()) {
378
+ if (part.type === 'file') {
379
+ const { checksum, size } = await storage.put(part.file)
380
+ // save the checksum in your database alongside whatever record it was uploaded against
381
+ }
382
+ }
383
+ })
384
+
385
+ server.app.get('/download/:checksum', async (req, res) => {
386
+ const stream = storage.get(req.params.checksum)
387
+ return res.send(stream)
388
+ })
389
+ ```
390
+
391
+ The `put` method streams the file to a temporary location while computing its SHA-256 hash, then re-reads the file to verify it was written correctly before moving it to its permanent checksum-based path. It returns the `checksum` (base64url-encoded) and `size` in bytes.
392
+
393
+ | Method | Description |
394
+ |--------|-------------|
395
+ | `init()` | Creates `tmpdir` and `permdir` if they don't exist. Call this before using the handler. |
396
+ | `put(stream)` | Streams a `Readable` to storage. Returns `{ checksum, size }`. |
397
+ | `get(checksum)` | Returns a `Readable` stream for the file. |
398
+ | `remove(checksum)` | Deletes the file. No-op if already gone. |
399
+ | `exists(checksum)` | Returns `true` if the file exists. |
400
+ | `fileSize(checksum)` | Returns the file size in bytes. |
401
+
402
+ Both `tmpdir` and `permdir` default to `/files/tmp/` and `/files/storage/` respectively. A default instance is also exported as `fileHandler` if the defaults work for your setup.
403
+
404
+ The `FileHandler` interface is also exported, so you can write your own storage backend with the same API. The idea is that your application accepts a `FileHandler` as configuration rather than depending on a concrete implementation. In development or simple deployments you use `FileSystemHandler`; in production a different instance of the same service could provide an S3-backed implementation — the route handlers don't change. The `postFormData` helper is useful for building cloud implementations, since it can stream files to a remote API without buffering.
405
+
406
+ ```javascript
407
+ import { FileSystemHandler, type FileHandler } from 'fastify-txstate'
408
+ import { S3FileHandler } from './s3filehandler.js'
409
+
410
+ const storage: FileHandler = process.env.FILE_STORAGE === 's3'
411
+ ? new S3FileHandler({ bucket: process.env.S3_BUCKET })
412
+ : new FileSystemHandler()
413
+ ```
414
+
415
+ # Analytics
416
+ The `analyticsPlugin` registers a `POST /analytics` endpoint that accepts an array of interaction events from your frontend, enriches them with server-side context (user agent, IP, authentication, timestamp), and flushes them in batches to a storage backend every 5 seconds.
417
+
418
+ ```javascript
419
+ import Server, { analyticsPlugin } from 'fastify-txstate'
420
+ const server = new Server()
421
+ server.app.register(analyticsPlugin, { appName: 'my-app' })
422
+ ```
423
+
424
+ The client sends events shaped like:
425
+ ```json
426
+ [{
427
+ "eventType": "ActionPanel.svelte",
428
+ "screen": "/pages/[id]",
429
+ "action": "Edit Page",
430
+ "target": "/sites/5/pages/12"
431
+ }]
432
+ ```
433
+
434
+ `eventType`, `screen`, and `action` are required. `target` and `additionalProperties` are optional.
435
+
436
+ ## Options
437
+ | Option | Description |
438
+ |--------|-------------|
439
+ | `appName` | **Required.** Identifies the application in stored events. |
440
+ | `analyticsClient` | An `AnalyticsClient` instance for storing events. See below for defaults. |
441
+ | `authorize` | A function `(req) => boolean` to restrict access to the endpoint. If it returns false, a 401 is thrown. |
442
+
443
+ ## Storage Clients
444
+ By default, the plugin picks a client automatically:
445
+
446
+ - If `ELASTICSEARCH_URL` is set, events are bulk-indexed into Elasticsearch using `ElasticAnalyticsClient`.
447
+ - Otherwise, in development (`NODE_ENV=development`), events are logged to the console.
448
+ - Otherwise, events are logged via the fastify logger (`LoggingAnalyticsClient`).
449
+
450
+ You can override this by passing your own `analyticsClient`. Extend the `AnalyticsClient` class and implement the `push` method:
451
+
452
+ ```javascript
453
+ import { AnalyticsClient, type StoredInteractionEvent } from 'fastify-txstate'
454
+
455
+ class BigQueryAnalyticsClient extends AnalyticsClient {
456
+ async push (events: StoredInteractionEvent[]) {
457
+ // write events to BigQuery, ClickHouse, etc.
458
+ }
459
+ }
460
+
461
+ server.app.register(analyticsPlugin, {
462
+ appName: 'my-app',
463
+ analyticsClient: new BigQueryAnalyticsClient()
464
+ })
465
+ ```
466
+
467
+ ### Elasticsearch Environment Variables
468
+ | Variable | Required | Description |
469
+ |----------|----------|-------------|
470
+ | `ELASTICSEARCH_URL` | Yes | Elasticsearch node URL. |
471
+ | `ELASTICSEARCH_USER` | No | Defaults to `elastic`. |
472
+ | `ELASTICSEARCH_PASS` | No | Elasticsearch password. |
473
+ | `ELASTICSEARCH_USEREVENTS_INDEX` | No | Index name. Defaults to `interaction-analytics`. |
474
+
475
+ ## Audience Validation
476
+ Audience validation is a way to ensure that tokens you accept were generated with your API in mind. This helps when the token's claims include authorization like role memberships specific to your app. An attacker could register their own app with identical role names and use their token for your API, unless you specify your API as the only valid audience via `JWT_TRUSTED_AUDIENCES` (or per-issuer `audiences` in `JWT_TRUSTED_ISSUERS`).
477
+
478
+ fastify-txstate is somewhat opinionated about storing authorization information in your authentication tokens. It's generally not a good idea - you'll end up with people staying in roles until their token expires, and be vulnerable to attacks like this. Let the authentication layer identify the user, and let your API match the user's identity with any authorization roles. To this end, `FastifyTxStateAuthInfo` doesn't have any spec for authorization-related claims.
479
+
480
+ Audience validation only becomes necessary if you use the `extraClaims` option to pull authorization claims from the token into your `auth` object.
481
+
482
+ # AI Agent Skills
483
+ If you use AI coding agents (Claude Code, Cursor, Copilot, etc.) to help build your APIs, this repo includes skill files that teach them how to use fastify-txstate. Copy the ones relevant to your project into your agent configuration (e.g. `.claude/` or `.cursor/rules/`):
484
+
485
+ | Skill | Description |
486
+ |-------|-------------|
487
+ | [`server-basics.md`](skills/server-basics.md) | Teaches the agent how to set up the server's error handling, SSL, health checks, logging |
488
+ | [`validation.md`](skills/validation.md) | Teaches the agent how to create POST/PUT endpoints that cooperate with svelte-forms to show validation feedback to users. |
489
+ | [`authentication.md`](skills/authentication.md) | Teaches the agent how to configure authentication for the server. |
490
+ | [`file-handling.md`](skills/file-handling.md) | Teaches the agent how to use our tools for streaming files to disk or swappable backends |
491
+ | [`endpoint-schemas.md`](skills/endpoint-schemas.md) | Teaches the agent how to define route schemas for type inference, validation, and Swagger documentation |
492
+ | [`analytics.md`](skills/analytics.md) | Teaches the agent how to configure the server for interaction event tracking in Elasticsearch or a custom storage client |
@@ -1,7 +1,7 @@
1
1
  import { type InteractionEvent } from '@txstate-mws/fastify-shared';
2
2
  import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify';
3
3
  import { type IBrowser, type IDevice, type IOS } from 'ua-parser-js';
4
- import { type FastifyTxStateAuthInfo } from '.';
4
+ import type { FastifyTxStateAuthInfo } from './server.ts';
5
5
  export interface StoredInteractionEvent extends InteractionEvent {
6
6
  '@timestamp': string;
7
7
  appName: string;
@@ -12,7 +12,9 @@ export interface StoredInteractionEvent extends InteractionEvent {
12
12
  user: {
13
13
  remoteip: string;
14
14
  ga?: string;
15
- } & FastifyTxStateAuthInfo;
15
+ } & Partial<FastifyTxStateAuthInfo> & {
16
+ username: string;
17
+ };
16
18
  }
17
19
  export declare class AnalyticsClient {
18
20
  push(events: StoredInteractionEvent[]): Promise<void>;
@@ -24,6 +26,7 @@ export declare class LoggingAnalyticsClient extends AnalyticsClient {
24
26
  }
25
27
  export declare class ElasticAnalyticsClient extends AnalyticsClient {
26
28
  private readonly elasticClient;
29
+ private userEventsIndex;
27
30
  constructor();
28
31
  push(events: StoredInteractionEvent[]): Promise<void>;
29
32
  }
package/lib/analytics.js CHANGED
@@ -1,23 +1,15 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ElasticAnalyticsClient = exports.LoggingAnalyticsClient = exports.AnalyticsClient = void 0;
7
- exports.analyticsPlugin = analyticsPlugin;
8
- const elasticsearch_1 = __importDefault(require("@elastic/elasticsearch"));
9
- const fastify_shared_1 = require("@txstate-mws/fastify-shared");
10
- const txstate_utils_1 = require("txstate-utils");
11
- const ua_parser_js_1 = require("ua-parser-js");
12
- const _1 = require(".");
13
- class AnalyticsClient {
1
+ import { Client as ElasticsearchClient } from '@elastic/elasticsearch';
2
+ import { interactionEvent } from '@txstate-mws/fastify-shared';
3
+ import { Cache, isBlank, omit, pick } from 'txstate-utils';
4
+ import { UAParser } from 'ua-parser-js';
5
+ import { HttpError } from "./error.js";
6
+ export class AnalyticsClient {
14
7
  async push(events) {
15
8
  for (const event of events)
16
- console.info('analytics event:', JSON.stringify((0, txstate_utils_1.pick)(event, 'eventType', 'screen', 'action', 'target')));
9
+ console.info('analytics event:', JSON.stringify(pick(event, 'eventType', 'screen', 'action', 'target'))); // eslint-disable-line no-console -- intentional dev fallback
17
10
  }
18
11
  }
19
- exports.AnalyticsClient = AnalyticsClient;
20
- class LoggingAnalyticsClient extends AnalyticsClient {
12
+ export class LoggingAnalyticsClient extends AnalyticsClient {
21
13
  logger;
22
14
  constructor(logger) {
23
15
  super();
@@ -28,12 +20,12 @@ class LoggingAnalyticsClient extends AnalyticsClient {
28
20
  this.logger.info({ analyticsEvent: event });
29
21
  }
30
22
  }
31
- exports.LoggingAnalyticsClient = LoggingAnalyticsClient;
32
- class ElasticAnalyticsClient extends AnalyticsClient {
23
+ export class ElasticAnalyticsClient extends AnalyticsClient {
33
24
  elasticClient;
25
+ userEventsIndex = process.env.ELASTICSEARCH_USEREVENTS_INDEX ?? 'interaction-analytics';
34
26
  constructor() {
35
27
  super();
36
- this.elasticClient = new elasticsearch_1.default.Client({
28
+ this.elasticClient = new ElasticsearchClient({
37
29
  node: process.env.ELASTICSEARCH_URL,
38
30
  auth: {
39
31
  username: process.env.ELASTICSEARCH_USER ?? 'elastic',
@@ -42,23 +34,26 @@ class ElasticAnalyticsClient extends AnalyticsClient {
42
34
  });
43
35
  }
44
36
  async push(events) {
45
- if (events.length)
46
- await this.elasticClient.bulk({ body: events.reduce((acc, event) => { acc.push({ index: { _index: process.env.ELASTICSEARCH_USEREVENTS_INDEX ?? 'interaction-analytics' } }, event); return acc; }, []) });
37
+ if (events.length) {
38
+ await this.elasticClient.bulk({ body: events.reduce((acc, event) => {
39
+ acc.push({ index: { _index: this.userEventsIndex } }, event);
40
+ return acc;
41
+ }, []) });
42
+ }
47
43
  }
48
44
  }
49
- exports.ElasticAnalyticsClient = ElasticAnalyticsClient;
50
- function analyticsPlugin(fastify, opts, done) {
45
+ export function analyticsPlugin(fastify, opts, done) {
51
46
  const environment = process.env.NODE_ENV;
52
- if ((0, txstate_utils_1.isBlank)(environment))
47
+ if (isBlank(environment))
53
48
  throw new Error('Must set NODE_ENV when reporting analytics.');
54
49
  const eventQueue = [];
55
- const analyticsClient = opts.analyticsClient ?? ((0, txstate_utils_1.isBlank)(process.env.ELASTICSEARCH_URL)
50
+ const analyticsClient = opts.analyticsClient ?? (isBlank(process.env.ELASTICSEARCH_URL)
56
51
  ? environment === 'development'
57
52
  ? new AnalyticsClient()
58
53
  : new LoggingAnalyticsClient(fastify.log)
59
54
  : new ElasticAnalyticsClient());
60
- const UACache = new txstate_utils_1.Cache(async (ua) => {
61
- const parser = new ua_parser_js_1.UAParser(ua);
55
+ const UACache = new Cache(async (ua) => {
56
+ const parser = new UAParser(ua);
62
57
  return parser.getResult();
63
58
  }, { freshseconds: 86400, staleseconds: 864000 });
64
59
  async function flushQueue() {
@@ -73,7 +68,7 @@ function analyticsPlugin(fastify, opts, done) {
73
68
  '@timestamp': queueItem.time,
74
69
  appName: opts.appName,
75
70
  environment,
76
- ...(0, txstate_utils_1.pick)(uaInfo, 'browser', 'device', 'os'),
71
+ ...pick(uaInfo, 'browser', 'device', 'os'),
77
72
  user: {
78
73
  remoteip: queueItem.remoteIp,
79
74
  ga: queueItem.gaCookie,
@@ -86,7 +81,7 @@ function analyticsPlugin(fastify, opts, done) {
86
81
  }
87
82
  catch (e) {
88
83
  eventQueue.push(...eventQueueSlice);
89
- console.error(e);
84
+ console.error(e); // eslint-disable-line no-console -- no logger available in this context
90
85
  }
91
86
  finally {
92
87
  setTimeout(() => { void flushQueue(); }, 5000);
@@ -100,16 +95,16 @@ function analyticsPlugin(fastify, opts, done) {
100
95
  remoteIp,
101
96
  ua: headers['user-agent'] ?? '',
102
97
  time: new Date().toISOString(),
103
- gaCookie: headers.cookie?.replace(/^.*?(?:_ga=([^;]+))?.*$/, '$1') ?? '',
98
+ gaCookie: headers.cookie?.replace(/^.*?(?:_ga=([^;]+))?.*$/v, '$1') ?? '',
104
99
  auth
105
100
  });
106
101
  }
107
102
  }
108
- fastify.post('/analytics', { schema: { body: { type: 'array', items: fastify_shared_1.interactionEvent }, response: { 202: { type: 'string', enum: ['OK'] } } } }, async (req, res) => {
103
+ fastify.post('/analytics', { schema: { body: { type: 'array', items: interactionEvent }, response: { 202: { type: 'string', enum: ['OK'] } } } }, async (req, res) => {
109
104
  const { auth } = req;
110
105
  if (opts.authorize && !opts.authorize(req))
111
- throw new _1.HttpError(401);
112
- queueEvents(auth ?? { username: 'unauthenticated' }, req.headers, req.ip, req.body);
106
+ throw new HttpError(401);
107
+ queueEvents(auth ? omit(auth, 'issuerConfig', 'token') : { username: 'unauthenticated' }, req.headers, req.ip, req.body);
113
108
  res.statusCode = 202;
114
109
  return 'OK';
115
110
  });
package/lib/error.d.ts CHANGED
@@ -1,18 +1,9 @@
1
1
  import type { ValidationMessage } from '@txstate-mws/fastify-shared';
2
- import type { FastifySchemaValidationError } from 'fastify/types/schema';
2
+ import type { FastifySchemaValidationError } from 'fastify';
3
3
  export declare class HttpError extends Error {
4
4
  statusCode: number;
5
5
  constructor(statusCode: number, message?: string);
6
6
  }
7
- /**
8
- * @deprecated This response format is less flexible than the one based on the ValidationMessage
9
- * interface. Use ValidationError or ValidationErrors instead, and adjust the client to expect
10
- * the new format.
11
- */
12
- export declare class FailedValidationError extends HttpError {
13
- errors: Record<string, string[]>;
14
- constructor(errors: Record<string, string[]>);
15
- }
16
7
  export declare class ValidationError extends HttpError {
17
8
  path?: string | undefined;
18
9
  type?: ValidationMessage["type"] | undefined;