@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 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
+ };
@@ -1,4 +1,4 @@
1
- import { createRoute } from "@hono/zod-openapi";
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({ token: z.string(), emailVerified: z.boolean().optional() });
16
- const ErrorResponse = z.object({ error: z.string() });
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: "Registered" },
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 token = await AuthService.register(identifier, body.password, metadata);
62
- setCookie(c, COOKIE_TOKEN, token, cookieOptions);
63
- return c.json({ token }, 201);
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: "Logged in" },
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: "Current user",
127
+ description: "Authenticated user's profile.",
116
128
  },
117
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
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 set" },
134
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
135
- 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
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: "Already verified" },
196
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
197
- 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
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: "Reset email sent if address is registered" },
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: { body: { content: { "application/json": { schema: z.object({ token: z.string(), password: z.string().min(8) }) } } } },
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 successfully" },
265
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error or invalid/expired token" },
266
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
267
- 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
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: "List of sessions for the current user",
352
+ description: "Sessions belonging to the authenticated user.",
313
353
  },
314
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
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: "Unauthorized" },
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);
@@ -1,7 +1,14 @@
1
1
  import type { SessionMetadata } from "../lib/session";
2
- export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<string>;
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>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastshotlabs/bunshot",
3
- "version": "0.0.10",
3
+ "version": "0.0.13",
4
4
  "description": "Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box",
5
5
  "repository": {
6
6
  "type": "git",