@lastshotlabs/bunshot 0.0.10 → 0.0.13
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 +171 -4
- package/dist/app.d.ts +30 -0
- package/dist/app.js +54 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/lib/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/routes/auth.js +109 -67
- package/dist/services/auth.d.ts +8 -1
- package/dist/services/auth.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,9 +142,8 @@ Drop a file in your `routes/` directory. It must export a `router`:
|
|
|
142
142
|
|
|
143
143
|
```ts
|
|
144
144
|
// src/routes/products.ts
|
|
145
|
-
import { createRoute } from "@hono/zod-openapi";
|
|
146
145
|
import { z } from "zod";
|
|
147
|
-
import { createRouter, userAuth } from "@lastshotlabs/bunshot";
|
|
146
|
+
import { createRoute, createRouter, userAuth } from "@lastshotlabs/bunshot";
|
|
148
147
|
|
|
149
148
|
export const router = createRouter();
|
|
150
149
|
|
|
@@ -178,6 +177,168 @@ routes/
|
|
|
178
177
|
detail.ts
|
|
179
178
|
```
|
|
180
179
|
|
|
180
|
+
### OpenAPI Schema Registration
|
|
181
|
+
|
|
182
|
+
Import `createRoute` from `@lastshotlabs/bunshot` (not from `@hono/zod-openapi`). The wrapper automatically registers every unnamed request body and response schema as a named entry in `components/schemas`. Schemas you already named via `registerSchema` are never overwritten.
|
|
183
|
+
|
|
184
|
+
Every Zod schema that appears in your OpenAPI spec ends up as a named entry in `components/schemas` — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
### Method 1 — Route-level auto-registration (via `createRoute`)
|
|
189
|
+
|
|
190
|
+
The most common case. When you define a route with `createRoute`, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.
|
|
191
|
+
|
|
192
|
+
**Naming convention**
|
|
193
|
+
|
|
194
|
+
| Route | Part | Generated name |
|
|
195
|
+
|-------|------|----------------|
|
|
196
|
+
| `POST /products` | request body | `CreateProductsRequest` |
|
|
197
|
+
| `POST /products` | 201 response | `CreateProductsResponse` |
|
|
198
|
+
| `GET /products/{id}` | 200 response | `GetProductsByIdResponse` |
|
|
199
|
+
| `DELETE /products/{id}` | 404 response | `DeleteProductsByIdNotFoundError` |
|
|
200
|
+
| `PATCH /products/{id}` | request body | `UpdateProductsByIdRequest` |
|
|
201
|
+
|
|
202
|
+
HTTP methods → verbs: `GET → Get`, `POST → Create`, `PUT → Replace`, `PATCH → Update`, `DELETE → Delete`.
|
|
203
|
+
|
|
204
|
+
Status codes → suffixes: `200/201/204 → Response`, `400 → BadRequestError`, `401 → UnauthorizedError`, `403 → ForbiddenError`, `404 → NotFoundError`, `409 → ConflictError`, `422 → ValidationError`, `429 → RateLimitError`, `500 → InternalError`, `501 → NotImplementedError`, `503 → UnavailableError`. Unknown codes fall back to the number.
|
|
205
|
+
|
|
206
|
+
**Limitation:** if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared `$ref`. Use Method 2 or 3 to fix this.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### Method 2 — Directory / glob auto-discovery (via `modelSchemas`)
|
|
211
|
+
|
|
212
|
+
Use this when you have schemas shared across multiple routes. Point `modelSchemas` at one or more directories and Bunshot imports every `.ts` file **before** routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same `$ref` in the spec.
|
|
213
|
+
|
|
214
|
+
**Naming:** export name with the trailing `Schema` suffix stripped (`LedgerItemSchema` → `"LedgerItem"`). Already-registered schemas are never overwritten.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// src/schemas/ledgerItem.ts
|
|
218
|
+
import { z } from "zod";
|
|
219
|
+
export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
|
|
220
|
+
// → auto-registered as "LedgerItem"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// src/config/index.ts
|
|
225
|
+
await createServer({
|
|
226
|
+
routesDir: import.meta.dir + "/routes",
|
|
227
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// src/routes/ledger.ts AND src/routes/ledgerDetail.ts
|
|
233
|
+
import { LedgerItemSchema } from "@schemas/ledgerItem"; // same Zod object instance
|
|
234
|
+
createRoute({ responses: { 200: { content: { "application/json": { schema: LedgerItemSchema } } } } });
|
|
235
|
+
// → $ref: "#/components/schemas/LedgerItem" in both routes
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Multiple directories and glob patterns**
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
modelSchemas: [
|
|
242
|
+
import.meta.dir + "/schemas", // dedicated schemas dir
|
|
243
|
+
import.meta.dir + "/models", // co-located with DB models
|
|
244
|
+
import.meta.dir + "/services/**/*.schema.ts", // selective glob
|
|
245
|
+
]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Full config object** — use when you need to set `registration` or mix paths and globs:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
modelSchemas: {
|
|
252
|
+
paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
|
|
253
|
+
registration: "auto", // default — auto-registers exports with suffix stripping
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**`registration: "explicit"`** — files are imported but nothing is auto-registered. Registration is left entirely to `registerSchema` / `registerSchemas` calls inside each file. Use this when you want zero magic and full name control:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### Method 3 — Batch explicit registration (via `registerSchemas`)
|
|
266
|
+
|
|
267
|
+
`registerSchemas` lets you name a group of schemas all at once. Object keys become the `components/schemas` names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// src/schemas/index.ts
|
|
271
|
+
import { registerSchemas } from "@lastshotlabs/bunshot";
|
|
272
|
+
import { z } from "zod";
|
|
273
|
+
|
|
274
|
+
export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
|
|
275
|
+
LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
|
|
276
|
+
Product: z.object({ id: z.string(), price: z.number() }),
|
|
277
|
+
ErrorResponse: z.object({ error: z.string() }),
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Pair with `registration: "explicit"` in `modelSchemas` so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### Method 4 — Single explicit registration (via `registerSchema`)
|
|
286
|
+
|
|
287
|
+
`registerSchema("Name", schema)` registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// src/schemas/errors.ts
|
|
291
|
+
import { registerSchema } from "@lastshotlabs/bunshot";
|
|
292
|
+
import { z } from "zod";
|
|
293
|
+
|
|
294
|
+
export const ErrorResponse = registerSchema("ErrorResponse",
|
|
295
|
+
z.object({ error: z.string() })
|
|
296
|
+
);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Registration is idempotent — calling `registerSchema` on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by `modelSchemas` auto-discovery: whichever runs first wins, and the other is silently skipped.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
### Priority and interaction
|
|
304
|
+
|
|
305
|
+
All four methods write to the same process-global registry. The rules are simple:
|
|
306
|
+
|
|
307
|
+
1. **First write wins** — once a schema has a name, it cannot be renamed.
|
|
308
|
+
2. **`modelSchemas` files are imported before routes**, so explicit calls inside them always take precedence over what `createRoute` would generate for the same object.
|
|
309
|
+
3. **`registerSchema` / `registerSchemas` take precedence over auto-discovery** when they appear at module top level (they run at import time, before `maybeAutoRegister` inspects the export list).
|
|
310
|
+
4. **`createRoute` never overwrites** a schema already in the registry — it only fills gaps.
|
|
311
|
+
|
|
312
|
+
**Decision guide:**
|
|
313
|
+
|
|
314
|
+
| Situation | Use |
|
|
315
|
+
|-----------|-----|
|
|
316
|
+
| Route-specific, one-off schema | `createRoute` auto-registration (Method 1) |
|
|
317
|
+
| Shared across routes, happy with suffix-stripped export name | `modelSchemas` auto-discovery (Method 2) |
|
|
318
|
+
| Shared across routes, want explicit names or batch control | `registerSchemas` (Method 3) |
|
|
319
|
+
| Single shared schema or custom name override | `registerSchema` (Method 4) |
|
|
320
|
+
|
|
321
|
+
**Protected routes**
|
|
322
|
+
|
|
323
|
+
Use `withSecurity` to declare security schemes on a route without breaking `c.req.valid()` type inference. (Inlining `security` directly in `createRoute({...})` causes TypeScript to collapse the handler's input types to `never`.)
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { createRoute, withSecurity } from "@lastshotlabs/bunshot";
|
|
327
|
+
|
|
328
|
+
router.openapi(
|
|
329
|
+
withSecurity(
|
|
330
|
+
createRoute({ method: "get", path: "/me", ... }),
|
|
331
|
+
{ cookieAuth: [] },
|
|
332
|
+
{ userToken: [] }
|
|
333
|
+
),
|
|
334
|
+
async (c) => {
|
|
335
|
+
const userId = c.get("authUserId"); // fully typed
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Pass each security scheme as a separate object argument. The security scheme names (`cookieAuth`, `userToken`, `bearerAuth`) are registered globally by `createApp`.
|
|
341
|
+
|
|
181
342
|
**Load order:** By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a `priority` number — lower values load first. Routes without a `priority` load last.
|
|
182
343
|
|
|
183
344
|
```ts
|
|
@@ -761,6 +922,11 @@ await createServer({
|
|
|
761
922
|
// Required
|
|
762
923
|
routesDir: import.meta.dir + "/routes",
|
|
763
924
|
|
|
925
|
+
// Shared schemas (imported before routes; see "Shared schemas across routes" above)
|
|
926
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
927
|
+
// modelSchemas: [dir + "/schemas", dir + "/models"], // multiple dirs
|
|
928
|
+
// modelSchemas: { paths: dir + "/schemas", registration: "explicit" }, // full object
|
|
929
|
+
|
|
764
930
|
// App metadata (shown in root endpoint + OpenAPI docs)
|
|
765
931
|
app: {
|
|
766
932
|
name: "My App", // default: "Bun Core API"
|
|
@@ -1564,7 +1730,8 @@ import {
|
|
|
1564
1730
|
cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching
|
|
1565
1731
|
|
|
1566
1732
|
// Utilities
|
|
1567
|
-
HttpError, log, validate, createRouter,
|
|
1733
|
+
HttpError, log, validate, createRouter, createRoute,
|
|
1734
|
+
registerSchema, registerSchemas, // named OpenAPI schema registration
|
|
1568
1735
|
getAppRoles, // returns the valid roles list configured at startup
|
|
1569
1736
|
|
|
1570
1737
|
// Constants
|
|
@@ -1572,7 +1739,7 @@ import {
|
|
|
1572
1739
|
|
|
1573
1740
|
// Types
|
|
1574
1741
|
type AppEnv, type AppVariables,
|
|
1575
|
-
type CreateServerConfig, type CreateAppConfig,
|
|
1742
|
+
type CreateServerConfig, type CreateAppConfig, type ModelSchemasConfig,
|
|
1576
1743
|
type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
|
|
1577
1744
|
type PrimaryField, type EmailVerificationConfig,
|
|
1578
1745
|
type SocketData, type WsConfig,
|
package/dist/app.d.ts
CHANGED
|
@@ -194,9 +194,39 @@ export interface SecurityConfig {
|
|
|
194
194
|
*/
|
|
195
195
|
botProtection?: BotProtectionConfig;
|
|
196
196
|
}
|
|
197
|
+
export interface ModelSchemasConfig {
|
|
198
|
+
/**
|
|
199
|
+
* One or more absolute directory paths or glob patterns containing shared Zod schemas.
|
|
200
|
+
* All matching .ts files are imported before routes so schemas are registered first.
|
|
201
|
+
* Optional when registration is "explicit" — in that case your registerSchema /
|
|
202
|
+
* registerSchemas calls run at the time each schema file is imported by a route.
|
|
203
|
+
* Examples:
|
|
204
|
+
* import.meta.dir + "/schemas"
|
|
205
|
+
* [import.meta.dir + "/schemas", import.meta.dir + "/models"]
|
|
206
|
+
* import.meta.dir + "/models/**\/*.schema.ts"
|
|
207
|
+
*/
|
|
208
|
+
paths?: string | string[];
|
|
209
|
+
/**
|
|
210
|
+
* How schemas found in the files are registered in `components/schemas`.
|
|
211
|
+
* - "auto" (default): exported Zod schemas are registered automatically. The export
|
|
212
|
+
* name is used as the schema name, with a trailing "Schema" suffix stripped
|
|
213
|
+
* (e.g. `LedgerItemSchema` → `"LedgerItem"`). Schemas already registered via
|
|
214
|
+
* `registerSchema` or `registerSchemas` inside the file are never overwritten.
|
|
215
|
+
* - "explicit": files are imported but registration is entirely up to the user —
|
|
216
|
+
* call `registerSchema` or `registerSchemas` inside each file.
|
|
217
|
+
*/
|
|
218
|
+
registration?: "auto" | "explicit";
|
|
219
|
+
}
|
|
197
220
|
export interface CreateAppConfig {
|
|
198
221
|
/** Absolute path to the service's routes directory (use import.meta.dir + "/routes") */
|
|
199
222
|
routesDir: string;
|
|
223
|
+
/**
|
|
224
|
+
* Shared Zod schema sources. Files are imported before route discovery so schemas
|
|
225
|
+
* are registered before any route references them.
|
|
226
|
+
* Accepts a directory path, an array of paths/globs, or a full ModelSchemasConfig object.
|
|
227
|
+
* Shorthand string/array defaults to registration: "auto".
|
|
228
|
+
*/
|
|
229
|
+
modelSchemas?: string | string[] | ModelSchemasConfig;
|
|
200
230
|
/** App name and version for the root endpoint and OpenAPI docs */
|
|
201
231
|
app?: AppMeta;
|
|
202
232
|
/** Auth, roles, and OAuth configuration */
|
package/dist/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { connectMongo, connectAuthMongo, connectAppMongo } from "./lib/mongo";
|
|
|
21
21
|
import { connectRedis } from "./lib/redis";
|
|
22
22
|
import { setSessionStore } from "./lib/session";
|
|
23
23
|
import { setCacheStore } from "./middleware/cacheResponse";
|
|
24
|
+
import { maybeAutoRegister } from "./lib/createRoute";
|
|
24
25
|
export const createApp = async (config) => {
|
|
25
26
|
const { routesDir, app: appConfig = {}, auth: authConfig = {}, security: securityConfig = {}, middleware = [], db = {}, } = config;
|
|
26
27
|
const appName = appConfig.name ?? "Bun Core API";
|
|
@@ -143,6 +144,42 @@ export const createApp = async (config) => {
|
|
|
143
144
|
for (const mw of middleware)
|
|
144
145
|
app.use(mw);
|
|
145
146
|
setAppName(appName);
|
|
147
|
+
// Schema pre-loading — import shared schema files before routes so registerSchema /
|
|
148
|
+
// registerSchemas calls run first, guaranteeing $ref instead of inline shapes.
|
|
149
|
+
const msConfig = config.modelSchemas;
|
|
150
|
+
if (msConfig) {
|
|
151
|
+
const { paths, registration = "auto" } = typeof msConfig === "string" || Array.isArray(msConfig)
|
|
152
|
+
? { paths: msConfig, registration: "auto" }
|
|
153
|
+
: msConfig;
|
|
154
|
+
const pathArray = paths ? (Array.isArray(paths) ? paths : [paths]) : [];
|
|
155
|
+
for (const entry of pathArray) {
|
|
156
|
+
// Normalize to forward slashes so splitting works on both Windows and Unix.
|
|
157
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
158
|
+
// Split glob patterns: everything before the first wildcard segment is the cwd.
|
|
159
|
+
let cwd;
|
|
160
|
+
let pattern;
|
|
161
|
+
if (!normalized.includes("*")) {
|
|
162
|
+
cwd = normalized;
|
|
163
|
+
pattern = "**/*.ts";
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const parts = normalized.split("/");
|
|
167
|
+
const starIdx = parts.findIndex((p) => p.includes("*"));
|
|
168
|
+
cwd = parts.slice(0, starIdx).join("/");
|
|
169
|
+
pattern = parts.slice(starIdx).join("/");
|
|
170
|
+
}
|
|
171
|
+
const schemaGlob = new Bun.Glob(pattern);
|
|
172
|
+
for await (const file of schemaGlob.scan({ cwd })) {
|
|
173
|
+
const mod = await import(`${cwd}/${file}`);
|
|
174
|
+
if (registration === "auto") {
|
|
175
|
+
for (const [exportName, value] of Object.entries(mod)) {
|
|
176
|
+
maybeAutoRegister(exportName, value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// "explicit": file imported; any registerSchema/registerSchemas calls inside already ran
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
146
183
|
// Core routes (auth, etc.)
|
|
147
184
|
const coreRoutesDir = import.meta.dir + "/routes";
|
|
148
185
|
const coreGlob = new Bun.Glob("*.ts");
|
|
@@ -186,6 +223,23 @@ export const createApp = async (config) => {
|
|
|
186
223
|
return c.json({ error: "Internal Server Error" }, 500);
|
|
187
224
|
});
|
|
188
225
|
app.notFound((c) => c.json({ error: "Not Found" }, 404));
|
|
226
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
|
|
227
|
+
type: "apiKey",
|
|
228
|
+
in: "cookie",
|
|
229
|
+
name: "token",
|
|
230
|
+
description: "Session cookie set automatically on login/register.",
|
|
231
|
+
});
|
|
232
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "userToken", {
|
|
233
|
+
type: "apiKey",
|
|
234
|
+
in: "header",
|
|
235
|
+
name: "x-user-token",
|
|
236
|
+
description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
|
|
237
|
+
});
|
|
238
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
239
|
+
type: "http",
|
|
240
|
+
scheme: "bearer",
|
|
241
|
+
description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
|
|
242
|
+
});
|
|
189
243
|
app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
|
|
190
244
|
app.get("/docs", Scalar({ url: "/openapi.json" }));
|
|
191
245
|
app.get("/sw.js", (c) => c.body("", 200, { "Content-Type": "application/javascript" }));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createApp } from "./app";
|
|
2
2
|
export { createServer } from "./server";
|
|
3
|
-
export type { CreateAppConfig, DbConfig, AppMeta, AuthConfig, AuthRateLimitConfig, OAuthConfig, SecurityConfig, BotProtectionConfig, PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./app";
|
|
3
|
+
export type { CreateAppConfig, ModelSchemasConfig, DbConfig, AppMeta, AuthConfig, AuthRateLimitConfig, OAuthConfig, SecurityConfig, BotProtectionConfig, PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./app";
|
|
4
4
|
export type { CreateServerConfig, WsConfig } from "./server";
|
|
5
5
|
export { appConnection, authConnection, mongoose, connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo } from "./lib/mongo";
|
|
6
6
|
export { connectRedis, disconnectRedis, getRedis } from "./lib/redis";
|
|
@@ -8,6 +8,7 @@ export { getAppRoles } from "./lib/appConfig";
|
|
|
8
8
|
export { HttpError } from "./lib/HttpError";
|
|
9
9
|
export { COOKIE_TOKEN, HEADER_USER_TOKEN } from "./lib/constants";
|
|
10
10
|
export { createRouter } from "./lib/context";
|
|
11
|
+
export { createRoute, withSecurity, registerSchema, registerSchemas } from "./lib/createRoute";
|
|
11
12
|
export type { AppEnv, AppVariables } from "./lib/context";
|
|
12
13
|
export { signToken, verifyToken } from "./lib/jwt";
|
|
13
14
|
export { log } from "./lib/logger";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { getAppRoles } from "./lib/appConfig";
|
|
|
9
9
|
export { HttpError } from "./lib/HttpError";
|
|
10
10
|
export { COOKIE_TOKEN, HEADER_USER_TOKEN } from "./lib/constants";
|
|
11
11
|
export { createRouter } from "./lib/context";
|
|
12
|
+
export { createRoute, withSecurity, registerSchema, registerSchemas } from "./lib/createRoute";
|
|
12
13
|
export { signToken, verifyToken } from "./lib/jwt";
|
|
13
14
|
export { log } from "./lib/logger";
|
|
14
15
|
export { createResetToken, consumeResetToken, setPasswordResetStore } from "./lib/resetPassword";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { RouteConfig } from "@hono/zod-openapi";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Registers a Zod schema as a named entry in `components/schemas`.
|
|
5
|
+
*
|
|
6
|
+
* Use this for shared schemas (e.g. shared error types, reusable response shapes)
|
|
7
|
+
* that aren't directly attached to a specific route. Schemas already registered
|
|
8
|
+
* under the same name are silently skipped.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* export const MySchema = registerSchema("MySchema", z.object({ id: z.string() }));
|
|
12
|
+
*/
|
|
13
|
+
export declare const registerSchema: <T extends ZodType>(name: string, schema: T) => T;
|
|
14
|
+
/**
|
|
15
|
+
* Registers multiple Zod schemas at once as named entries in `components/schemas`.
|
|
16
|
+
* Object keys become the schema names. Returns the same object so you can
|
|
17
|
+
* destructure or re-export the schemas normally.
|
|
18
|
+
*
|
|
19
|
+
* Schemas already registered (e.g. via a prior `registerSchema` call) are skipped.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* export const { LedgerItem, Product } = registerSchemas({
|
|
23
|
+
* LedgerItem: z.object({ id: z.string(), amount: z.number() }),
|
|
24
|
+
* Product: z.object({ id: z.string(), price: z.number() }),
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
export declare const registerSchemas: <T extends Record<string, ZodType>>(schemas: T) => T;
|
|
28
|
+
/**
|
|
29
|
+
* Auto-registers a module export as a named OpenAPI schema.
|
|
30
|
+
* Used internally by modelSchemas auto-discovery in createApp.
|
|
31
|
+
* Strips a trailing "Schema" suffix from the export name.
|
|
32
|
+
* Skips non-Zod values and already-registered schemas.
|
|
33
|
+
*/
|
|
34
|
+
export declare function maybeAutoRegister(exportName: string, value: unknown): void;
|
|
35
|
+
/**
|
|
36
|
+
* Adds an OpenAPI `security` requirement to a route without affecting TypeScript
|
|
37
|
+
* type inference on the handler. Pass each security scheme as a separate object.
|
|
38
|
+
*
|
|
39
|
+
* Use this instead of inlining `security` in `createRoute(...)` — inlining a
|
|
40
|
+
* field typed as `{ [name: string]: string[] }` breaks `c.req.valid()` inference.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* router.openapi(
|
|
44
|
+
* withSecurity(createRoute({ method: "get", path: "/me", ... }), { cookieAuth: [] }, { userToken: [] }),
|
|
45
|
+
* async (c) => { ... }
|
|
46
|
+
* )
|
|
47
|
+
*/
|
|
48
|
+
export declare const withSecurity: <T extends RouteConfig>(route: T, ...schemes: Array<Record<string, string[]>>) => T;
|
|
49
|
+
/**
|
|
50
|
+
* Drop-in replacement for `createRoute` from `@hono/zod-openapi`.
|
|
51
|
+
*
|
|
52
|
+
* Automatically registers unnamed request body and response schemas as named
|
|
53
|
+
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
54
|
+
* inlined at every use site. Generated names follow the convention:
|
|
55
|
+
*
|
|
56
|
+
* {Method}{PathSegments}Body — request body
|
|
57
|
+
* {Method}{PathSegments}{StatusCode} — response body
|
|
58
|
+
*
|
|
59
|
+
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
60
|
+
*/
|
|
61
|
+
export declare const createRoute: <T extends RouteConfig>(config: T) => T;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createRoute as _createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { getRefId, zodToOpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
3
|
+
const STATUS_SUFFIX = {
|
|
4
|
+
"200": "Response",
|
|
5
|
+
"201": "Response",
|
|
6
|
+
"204": "Response",
|
|
7
|
+
"400": "BadRequestError",
|
|
8
|
+
"401": "UnauthorizedError",
|
|
9
|
+
"403": "ForbiddenError",
|
|
10
|
+
"404": "NotFoundError",
|
|
11
|
+
"409": "ConflictError",
|
|
12
|
+
"422": "ValidationError",
|
|
13
|
+
"429": "RateLimitError",
|
|
14
|
+
"500": "InternalError",
|
|
15
|
+
"501": "NotImplementedError",
|
|
16
|
+
"503": "UnavailableError",
|
|
17
|
+
};
|
|
18
|
+
const METHOD_VERB = {
|
|
19
|
+
get: "Get",
|
|
20
|
+
post: "Create",
|
|
21
|
+
put: "Replace",
|
|
22
|
+
patch: "Update",
|
|
23
|
+
delete: "Delete",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Converts a route method + path into a PascalCase base name for auto-generated schema names.
|
|
27
|
+
* Examples:
|
|
28
|
+
* POST /ledger-items → CreateLedgerItems
|
|
29
|
+
* GET /ledger-items/{id} → GetLedgerItemsById
|
|
30
|
+
* DELETE /auth/sessions/{sessionId} → DeleteAuthSessionsBySessionId
|
|
31
|
+
*/
|
|
32
|
+
function toBaseName(method, path) {
|
|
33
|
+
const m = METHOD_VERB[method.toLowerCase()] ?? (method.charAt(0).toUpperCase() + method.slice(1).toLowerCase());
|
|
34
|
+
const segments = path
|
|
35
|
+
.split("/")
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((seg) => {
|
|
38
|
+
if (seg.startsWith("{") && seg.endsWith("}")) {
|
|
39
|
+
const param = seg.slice(1, -1);
|
|
40
|
+
return "By" + param.charAt(0).toUpperCase() + param.slice(1);
|
|
41
|
+
}
|
|
42
|
+
// kebab-case and plain segments → PascalCase
|
|
43
|
+
return seg.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^[a-z]/, (c) => c.toUpperCase());
|
|
44
|
+
});
|
|
45
|
+
return m + segments.join("");
|
|
46
|
+
}
|
|
47
|
+
function maybeRegister(schema, name) {
|
|
48
|
+
if (!schema || typeof schema !== "object" || !("_def" in schema))
|
|
49
|
+
return;
|
|
50
|
+
if (getRefId(schema))
|
|
51
|
+
return; // already named via .openapi()
|
|
52
|
+
// Write directly to the registry instead of calling schema.openapi(name) — the
|
|
53
|
+
// .openapi() method requires extendZodWithOpenApi() to have been called on the
|
|
54
|
+
// same zod instance that created the schema, which isn't guaranteed in tenant apps.
|
|
55
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Registers a Zod schema as a named entry in `components/schemas`.
|
|
59
|
+
*
|
|
60
|
+
* Use this for shared schemas (e.g. shared error types, reusable response shapes)
|
|
61
|
+
* that aren't directly attached to a specific route. Schemas already registered
|
|
62
|
+
* under the same name are silently skipped.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* export const MySchema = registerSchema("MySchema", z.object({ id: z.string() }));
|
|
66
|
+
*/
|
|
67
|
+
export const registerSchema = (name, schema) => {
|
|
68
|
+
if (!getRefId(schema)) {
|
|
69
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
70
|
+
}
|
|
71
|
+
return schema;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Registers multiple Zod schemas at once as named entries in `components/schemas`.
|
|
75
|
+
* Object keys become the schema names. Returns the same object so you can
|
|
76
|
+
* destructure or re-export the schemas normally.
|
|
77
|
+
*
|
|
78
|
+
* Schemas already registered (e.g. via a prior `registerSchema` call) are skipped.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* export const { LedgerItem, Product } = registerSchemas({
|
|
82
|
+
* LedgerItem: z.object({ id: z.string(), amount: z.number() }),
|
|
83
|
+
* Product: z.object({ id: z.string(), price: z.number() }),
|
|
84
|
+
* });
|
|
85
|
+
*/
|
|
86
|
+
export const registerSchemas = (schemas) => {
|
|
87
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
88
|
+
if (!getRefId(schema)) {
|
|
89
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return schemas;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Auto-registers a module export as a named OpenAPI schema.
|
|
96
|
+
* Used internally by modelSchemas auto-discovery in createApp.
|
|
97
|
+
* Strips a trailing "Schema" suffix from the export name.
|
|
98
|
+
* Skips non-Zod values and already-registered schemas.
|
|
99
|
+
*/
|
|
100
|
+
export function maybeAutoRegister(exportName, value) {
|
|
101
|
+
if (!value || typeof value !== "object" || !("_def" in value))
|
|
102
|
+
return;
|
|
103
|
+
if (getRefId(value))
|
|
104
|
+
return;
|
|
105
|
+
const name = exportName.endsWith("Schema")
|
|
106
|
+
? exportName.slice(0, -"Schema".length)
|
|
107
|
+
: exportName;
|
|
108
|
+
zodToOpenAPIRegistry.add(value, { _internal: { refId: name } });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Adds an OpenAPI `security` requirement to a route without affecting TypeScript
|
|
112
|
+
* type inference on the handler. Pass each security scheme as a separate object.
|
|
113
|
+
*
|
|
114
|
+
* Use this instead of inlining `security` in `createRoute(...)` — inlining a
|
|
115
|
+
* field typed as `{ [name: string]: string[] }` breaks `c.req.valid()` inference.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* router.openapi(
|
|
119
|
+
* withSecurity(createRoute({ method: "get", path: "/me", ... }), { cookieAuth: [] }, { userToken: [] }),
|
|
120
|
+
* async (c) => { ... }
|
|
121
|
+
* )
|
|
122
|
+
*/
|
|
123
|
+
export const withSecurity = (route, ...schemes) => Object.assign(route, { security: schemes });
|
|
124
|
+
/**
|
|
125
|
+
* Drop-in replacement for `createRoute` from `@hono/zod-openapi`.
|
|
126
|
+
*
|
|
127
|
+
* Automatically registers unnamed request body and response schemas as named
|
|
128
|
+
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
129
|
+
* inlined at every use site. Generated names follow the convention:
|
|
130
|
+
*
|
|
131
|
+
* {Method}{PathSegments}Body — request body
|
|
132
|
+
* {Method}{PathSegments}{StatusCode} — response body
|
|
133
|
+
*
|
|
134
|
+
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
135
|
+
*/
|
|
136
|
+
export const createRoute = (config) => {
|
|
137
|
+
const base = toBaseName(config.method, config.path);
|
|
138
|
+
// Auto-name the JSON request body schema if present and unnamed
|
|
139
|
+
const bodySchema = config.request?.body?.content?.["application/json"]?.schema;
|
|
140
|
+
maybeRegister(bodySchema, `${base}Request`);
|
|
141
|
+
// Auto-name each JSON response schema if present and unnamed
|
|
142
|
+
for (const [status, response] of Object.entries(config.responses ?? {})) {
|
|
143
|
+
const resSchema = response?.content?.["application/json"]?.schema;
|
|
144
|
+
maybeRegister(resSchema, `${base}${STATUS_SUFFIX[status] ?? status}`);
|
|
145
|
+
}
|
|
146
|
+
return _createRoute(config);
|
|
147
|
+
};
|
package/dist/routes/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRoute } from "
|
|
1
|
+
import { createRoute, withSecurity } from "../lib/createRoute";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
4
|
import * as AuthService from "../services/auth";
|
|
@@ -12,8 +12,14 @@ import { getVerificationToken, deleteVerificationToken, createVerificationToken
|
|
|
12
12
|
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
13
13
|
import { getUserSessions, deleteSession } from "../lib/session";
|
|
14
14
|
const isProd = process.env.NODE_ENV === "production";
|
|
15
|
-
const TokenResponse = z.object({
|
|
16
|
-
|
|
15
|
+
const TokenResponse = z.object({
|
|
16
|
+
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
|
|
17
|
+
userId: z.string().describe("Unique user ID."),
|
|
18
|
+
email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
|
|
19
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
|
|
20
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
|
|
21
|
+
}).openapi("TokenResponse");
|
|
22
|
+
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
17
23
|
const tags = ["Auth"];
|
|
18
24
|
const cookieOptions = {
|
|
19
25
|
httpOnly: true,
|
|
@@ -39,13 +45,15 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
39
45
|
router.openapi(createRoute({
|
|
40
46
|
method: "post",
|
|
41
47
|
path: "/auth/register",
|
|
48
|
+
summary: "Register a new account",
|
|
49
|
+
description: "Creates a new user account and returns a JWT session token. The token is also set as an HttpOnly session cookie. Rate-limited by IP.",
|
|
42
50
|
tags,
|
|
43
|
-
request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
|
|
51
|
+
request: { body: { content: { "application/json": { schema: RegisterSchema } }, description: "Registration credentials." } },
|
|
44
52
|
responses: {
|
|
45
|
-
201: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
46
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
53
|
+
201: { content: { "application/json": { schema: TokenResponse } }, description: "Account created. Returns a session token." },
|
|
54
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. missing field, password too short)." },
|
|
47
55
|
409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
|
|
48
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
56
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
49
57
|
},
|
|
50
58
|
}), async (c) => {
|
|
51
59
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -58,20 +66,22 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
58
66
|
ipAddress: ip !== "unknown" ? ip : undefined,
|
|
59
67
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
60
68
|
};
|
|
61
|
-
const
|
|
62
|
-
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
63
|
-
return c.json(
|
|
69
|
+
const result = await AuthService.register(identifier, body.password, metadata);
|
|
70
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
71
|
+
return c.json(result, 201);
|
|
64
72
|
});
|
|
65
73
|
router.openapi(createRoute({
|
|
66
74
|
method: "post",
|
|
67
75
|
path: "/auth/login",
|
|
76
|
+
summary: "Log in",
|
|
77
|
+
description: "Authenticates with credentials and returns a JWT session token. The token is also set as an HttpOnly session cookie. Failed attempts are rate-limited per identifier.",
|
|
68
78
|
tags,
|
|
69
|
-
request: { body: { content: { "application/json": { schema: LoginSchema } } } },
|
|
79
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials." } },
|
|
70
80
|
responses: {
|
|
71
|
-
200: { content: { "application/json": { schema: TokenResponse } }, description: "
|
|
72
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
|
|
73
|
-
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
|
|
74
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
81
|
+
200: { content: { "application/json": { schema: TokenResponse } }, description: "Authenticated. Returns a session token." },
|
|
82
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
83
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified. Verification is required before login." },
|
|
84
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many failed login attempts for this identifier. Try again later." },
|
|
75
85
|
},
|
|
76
86
|
}), async (c) => {
|
|
77
87
|
const body = c.req.valid("json");
|
|
@@ -96,27 +106,29 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
96
106
|
}
|
|
97
107
|
});
|
|
98
108
|
router.use("/auth/me", userAuth);
|
|
99
|
-
router.openapi(createRoute({
|
|
109
|
+
router.openapi(withSecurity(createRoute({
|
|
100
110
|
method: "get",
|
|
101
111
|
path: "/auth/me",
|
|
112
|
+
summary: "Get current user",
|
|
113
|
+
description: "Returns the authenticated user's profile. Requires a valid session via cookie or x-user-token header.",
|
|
102
114
|
tags,
|
|
103
115
|
responses: {
|
|
104
116
|
200: {
|
|
105
117
|
content: {
|
|
106
118
|
"application/json": {
|
|
107
119
|
schema: z.object({
|
|
108
|
-
userId: z.string(),
|
|
109
|
-
email: z.string().optional(),
|
|
110
|
-
emailVerified: z.boolean().optional(),
|
|
111
|
-
googleLinked: z.boolean().optional(),
|
|
120
|
+
userId: z.string().describe("Unique user ID."),
|
|
121
|
+
email: z.string().optional().describe("User's email address."),
|
|
122
|
+
emailVerified: z.boolean().optional().describe("Whether the email address has been verified."),
|
|
123
|
+
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked."),
|
|
112
124
|
}),
|
|
113
125
|
},
|
|
114
126
|
},
|
|
115
|
-
description: "
|
|
127
|
+
description: "Authenticated user's profile.",
|
|
116
128
|
},
|
|
117
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
129
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
118
130
|
},
|
|
119
|
-
}), async (c) => {
|
|
131
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
120
132
|
const authUserId = c.get("authUserId");
|
|
121
133
|
const adapter = getAuthAdapter();
|
|
122
134
|
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
@@ -124,17 +136,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
124
136
|
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
125
137
|
});
|
|
126
138
|
router.use("/auth/set-password", userAuth);
|
|
127
|
-
router.openapi(createRoute({
|
|
139
|
+
router.openapi(withSecurity(createRoute({
|
|
128
140
|
method: "post",
|
|
129
141
|
path: "/auth/set-password",
|
|
142
|
+
summary: "Set or update password",
|
|
143
|
+
description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. Requires a valid session.",
|
|
130
144
|
tags,
|
|
131
|
-
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
|
|
145
|
+
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8).describe("New password. Minimum 8 characters.") }) } }, description: "New password." } },
|
|
132
146
|
responses: {
|
|
133
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password
|
|
134
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
135
|
-
|
|
147
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
|
|
148
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
|
|
149
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
150
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
136
151
|
},
|
|
137
|
-
}), async (c) => {
|
|
152
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
138
153
|
const adapter = getAuthAdapter();
|
|
139
154
|
if (!adapter.setPassword) {
|
|
140
155
|
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
@@ -148,9 +163,11 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
148
163
|
router.openapi(createRoute({
|
|
149
164
|
method: "post",
|
|
150
165
|
path: "/auth/logout",
|
|
166
|
+
summary: "Log out",
|
|
167
|
+
description: "Invalidates the current session and clears the session cookie. Safe to call even without an active session.",
|
|
151
168
|
tags,
|
|
152
169
|
responses: {
|
|
153
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
|
|
170
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out. Session is invalidated and cookie is cleared." },
|
|
154
171
|
},
|
|
155
172
|
}), async (c) => {
|
|
156
173
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
@@ -163,12 +180,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
163
180
|
router.openapi(createRoute({
|
|
164
181
|
method: "post",
|
|
165
182
|
path: "/auth/verify-email",
|
|
183
|
+
summary: "Verify email address",
|
|
184
|
+
description: "Consumes a single-use email verification token and marks the account as verified. The token is delivered by the `emailVerification.onSend` callback configured in CreateAppConfig. Rate-limited by IP.",
|
|
166
185
|
tags,
|
|
167
|
-
request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
|
|
186
|
+
request: { body: { content: { "application/json": { schema: z.object({ token: z.string().describe("Single-use verification token received via email.") }) } }, description: "Verification token." } },
|
|
168
187
|
responses: {
|
|
169
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
|
|
170
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
|
|
171
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
188
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified successfully." },
|
|
189
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired verification token." },
|
|
190
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
172
191
|
},
|
|
173
192
|
}), async (c) => {
|
|
174
193
|
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
@@ -186,17 +205,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
186
205
|
return c.json({ message: "Email verified" }, 200);
|
|
187
206
|
});
|
|
188
207
|
router.use("/auth/resend-verification", userAuth);
|
|
189
|
-
router.openapi(createRoute({
|
|
208
|
+
router.openapi(withSecurity(createRoute({
|
|
190
209
|
method: "post",
|
|
191
210
|
path: "/auth/resend-verification",
|
|
211
|
+
summary: "Resend verification email",
|
|
212
|
+
description: "Sends a new verification email to the authenticated user's address. Returns 400 if already verified. Rate-limited per user. Requires a valid session.",
|
|
192
213
|
tags,
|
|
193
214
|
responses: {
|
|
194
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
|
|
195
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
196
|
-
|
|
197
|
-
|
|
215
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
216
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
|
|
217
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
218
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this user. Try again later." },
|
|
219
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
198
220
|
},
|
|
199
|
-
}), async (c) => {
|
|
221
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
200
222
|
const adapter = getAuthAdapter();
|
|
201
223
|
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
202
224
|
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
@@ -221,12 +243,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
221
243
|
router.openapi(createRoute({
|
|
222
244
|
method: "post",
|
|
223
245
|
path: "/auth/forgot-password",
|
|
246
|
+
summary: "Request password reset",
|
|
247
|
+
description: "Sends a password reset email if the address is registered. Always returns 200 regardless of whether the address exists, to prevent email enumeration. Rate-limited by both IP and email address.",
|
|
224
248
|
tags,
|
|
225
|
-
request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email() }) } } } },
|
|
249
|
+
request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email().describe("Email address to send the reset link to.") }) } }, description: "Email address for the account to reset." } },
|
|
226
250
|
responses: {
|
|
227
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "
|
|
228
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
229
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
251
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Request received. A reset email will be sent if the address is registered." },
|
|
252
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. not a valid email address)." },
|
|
253
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
230
254
|
},
|
|
231
255
|
}), async (c) => {
|
|
232
256
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -258,13 +282,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
258
282
|
router.openapi(createRoute({
|
|
259
283
|
method: "post",
|
|
260
284
|
path: "/auth/reset-password",
|
|
285
|
+
summary: "Reset password",
|
|
286
|
+
description: "Consumes a single-use reset token and sets a new password. All active sessions are revoked after a successful reset to invalidate any stolen JWTs. Rate-limited by IP.",
|
|
261
287
|
tags,
|
|
262
|
-
request: {
|
|
288
|
+
request: {
|
|
289
|
+
body: {
|
|
290
|
+
content: {
|
|
291
|
+
"application/json": {
|
|
292
|
+
schema: z.object({
|
|
293
|
+
token: z.string().describe("Single-use reset token received via email."),
|
|
294
|
+
password: z.string().min(8).describe("New password. Minimum 8 characters."),
|
|
295
|
+
}),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
description: "Reset token and new password.",
|
|
299
|
+
},
|
|
300
|
+
},
|
|
263
301
|
responses: {
|
|
264
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset
|
|
265
|
-
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error or invalid
|
|
266
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
267
|
-
501: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
302
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset. All sessions have been revoked." },
|
|
303
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or the reset token is invalid or expired." },
|
|
304
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many reset attempts from this IP. Try again later." },
|
|
305
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
268
306
|
},
|
|
269
307
|
}), async (c) => {
|
|
270
308
|
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
@@ -292,43 +330,47 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
292
330
|
// Session management
|
|
293
331
|
// ---------------------------------------------------------------------------
|
|
294
332
|
const SessionInfoSchema = z.object({
|
|
295
|
-
sessionId: z.string(),
|
|
296
|
-
createdAt: z.number(),
|
|
297
|
-
lastActiveAt: z.number(),
|
|
298
|
-
expiresAt: z.number(),
|
|
299
|
-
ipAddress: z.string().optional(),
|
|
300
|
-
userAgent: z.string().optional(),
|
|
301
|
-
isActive: z.boolean(),
|
|
302
|
-
});
|
|
333
|
+
sessionId: z.string().describe("Unique session identifier (UUID)."),
|
|
334
|
+
createdAt: z.number().describe("Unix timestamp (ms) when the session was created."),
|
|
335
|
+
lastActiveAt: z.number().describe("Unix timestamp (ms) of the most recent authenticated request (updated when trackLastActive is enabled)."),
|
|
336
|
+
expiresAt: z.number().describe("Unix timestamp (ms) when the session expires."),
|
|
337
|
+
ipAddress: z.string().optional().describe("IP address of the client at session creation."),
|
|
338
|
+
userAgent: z.string().optional().describe("User-agent string of the client at session creation."),
|
|
339
|
+
isActive: z.boolean().describe("Whether the session is currently valid and unexpired."),
|
|
340
|
+
}).openapi("SessionInfo");
|
|
303
341
|
router.use("/auth/sessions", userAuth);
|
|
304
342
|
router.use("/auth/sessions/*", userAuth);
|
|
305
|
-
router.openapi(createRoute({
|
|
343
|
+
router.openapi(withSecurity(createRoute({
|
|
306
344
|
method: "get",
|
|
307
345
|
path: "/auth/sessions",
|
|
346
|
+
summary: "List sessions",
|
|
347
|
+
description: "Returns all sessions for the authenticated user. Includes inactive sessions when `sessionPolicy.includeInactiveSessions` is enabled. Requires a valid session.",
|
|
308
348
|
tags,
|
|
309
349
|
responses: {
|
|
310
350
|
200: {
|
|
311
351
|
content: { "application/json": { schema: z.object({ sessions: z.array(SessionInfoSchema) }) } },
|
|
312
|
-
description: "
|
|
352
|
+
description: "Sessions belonging to the authenticated user.",
|
|
313
353
|
},
|
|
314
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
354
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
315
355
|
},
|
|
316
|
-
}), async (c) => {
|
|
356
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
317
357
|
const userId = c.get("authUserId");
|
|
318
358
|
const sessions = await getUserSessions(userId);
|
|
319
359
|
return c.json({ sessions }, 200);
|
|
320
360
|
});
|
|
321
|
-
router.openapi(createRoute({
|
|
361
|
+
router.openapi(withSecurity(createRoute({
|
|
322
362
|
method: "delete",
|
|
323
363
|
path: "/auth/sessions/{sessionId}",
|
|
364
|
+
summary: "Revoke a session",
|
|
365
|
+
description: "Revokes a specific session by ID. Users can only revoke their own sessions. Useful for 'sign out of other devices' flows. Requires a valid session.",
|
|
324
366
|
tags,
|
|
325
|
-
request: { params: z.object({ sessionId: z.string() }) },
|
|
367
|
+
request: { params: z.object({ sessionId: z.string().describe("UUID of the session to revoke.") }) },
|
|
326
368
|
responses: {
|
|
327
|
-
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked" },
|
|
328
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
329
|
-
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found" },
|
|
369
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked successfully." },
|
|
370
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
371
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found or does not belong to the authenticated user." },
|
|
330
372
|
},
|
|
331
|
-
}), async (c) => {
|
|
373
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
332
374
|
const userId = c.get("authUserId");
|
|
333
375
|
const { sessionId } = c.req.valid("param");
|
|
334
376
|
const sessions = await getUserSessions(userId);
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { SessionMetadata } from "../lib/session";
|
|
2
|
-
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<
|
|
2
|
+
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
|
|
3
|
+
token: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
}>;
|
|
3
7
|
export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<{
|
|
4
8
|
token: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
email?: string;
|
|
5
11
|
emailVerified?: boolean;
|
|
12
|
+
googleLinked?: boolean;
|
|
6
13
|
}>;
|
|
7
14
|
export declare const logout: (token: string | null) => Promise<void>;
|
package/dist/services/auth.js
CHANGED
|
@@ -27,7 +27,7 @@ export const register = async (identifier, password, metadata) => {
|
|
|
27
27
|
console.error("[email-verification] Failed to send verification email:", e);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
return token;
|
|
30
|
+
return { token, userId: user.id, email: identifier };
|
|
31
31
|
};
|
|
32
32
|
export const login = async (identifier, password, metadata) => {
|
|
33
33
|
const adapter = getAuthAdapter();
|
|
@@ -41,6 +41,8 @@ export const login = async (identifier, password, metadata) => {
|
|
|
41
41
|
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
42
42
|
await evictOldestSession(user.id);
|
|
43
43
|
}
|
|
44
|
+
const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
|
|
45
|
+
const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
44
46
|
const evConfig = getEmailVerificationConfig();
|
|
45
47
|
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
46
48
|
const verified = await adapter.getEmailVerified(user.id);
|
|
@@ -48,10 +50,10 @@ export const login = async (identifier, password, metadata) => {
|
|
|
48
50
|
throw new HttpError(403, "Email not verified");
|
|
49
51
|
}
|
|
50
52
|
await createSession(user.id, token, sessionId, metadata);
|
|
51
|
-
return { token, emailVerified: verified };
|
|
53
|
+
return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked };
|
|
52
54
|
}
|
|
53
55
|
await createSession(user.id, token, sessionId, metadata);
|
|
54
|
-
return { token };
|
|
56
|
+
return { token, userId: user.id, email: fullUser?.email, googleLinked };
|
|
55
57
|
};
|
|
56
58
|
export const logout = async (token) => {
|
|
57
59
|
if (token) {
|
package/package.json
CHANGED