@lastshotlabs/bunshot 0.0.21 → 0.0.27

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.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -0,0 +1,227 @@
1
+ import { z } from "zod";
2
+ import { createRouter } from "../lib/context";
3
+ import { createRoute } from "../lib/createRoute";
4
+ import { userAuth } from "../middleware/userAuth";
5
+ import { getStorageAdapter, generateUploadKeyFromFilename } from "../lib/upload";
6
+ import { getSigningConfig, getSigningSecret } from "../lib/appConfig";
7
+ import { createPresignedUrl } from "../lib/signing";
8
+ import { getUploadRecord, deleteUploadRecord, registerUpload } from "../lib/uploadRegistry";
9
+ const tags = ["Uploads"];
10
+ async function checkUploadAccess(action, key, userId, tenantId, config) {
11
+ const record = await getUploadRecord(key);
12
+ const authorize = config.authorization?.authorize;
13
+ const allowExternalKeys = config.allowExternalKeys ?? false;
14
+ if (record) {
15
+ // If the registry record has a tenantId, the requester must match — period.
16
+ if (record.tenantId && record.tenantId !== tenantId) {
17
+ return { allowed: false, notFound: false };
18
+ }
19
+ // Owner match → allow
20
+ if (record.ownerUserId && record.ownerUserId === userId) {
21
+ return { allowed: true, notFound: false };
22
+ }
23
+ // No owner or owner mismatch → try callback
24
+ if (authorize) {
25
+ const ok = await authorize({ action, key, userId: userId ?? undefined, tenantId: tenantId ?? undefined });
26
+ return { allowed: ok, notFound: false };
27
+ }
28
+ return { allowed: false, notFound: false };
29
+ }
30
+ // Record not in registry
31
+ if (allowExternalKeys) {
32
+ if (authorize) {
33
+ const ok = await authorize({ action, key, userId: userId ?? undefined, tenantId: tenantId ?? undefined });
34
+ return { allowed: ok, notFound: false };
35
+ }
36
+ return { allowed: false, notFound: false };
37
+ }
38
+ return { allowed: false, notFound: true };
39
+ }
40
+ export const createUploadsRouter = (config) => {
41
+ const router = createRouter();
42
+ const basePath = (config.path ?? "/uploads").replace(/\/$/, "");
43
+ router.use(`${basePath}/*`, userAuth);
44
+ const BLOCKED_MIME_TYPES = new Set([
45
+ "application/x-executable",
46
+ "application/x-sh",
47
+ "application/x-msdownload",
48
+ "text/html",
49
+ "application/x-httpd-php",
50
+ "application/javascript",
51
+ "text/javascript",
52
+ ]);
53
+ const presignRoute = createRoute({
54
+ method: "post",
55
+ path: `${basePath}/presign`,
56
+ tags,
57
+ summary: "Generate presigned upload URL",
58
+ request: {
59
+ body: {
60
+ content: {
61
+ "application/json": {
62
+ schema: z.object({
63
+ filename: z.string().optional().describe("Original filename (used to derive the storage key extension)"),
64
+ mimeType: z.string().optional().describe("MIME type of the file"),
65
+ expirySeconds: z.number().int().positive().optional().describe("URL expiry in seconds"),
66
+ maxBytes: z.number().int().positive().max(100 * 1024 * 1024).optional()
67
+ .describe("Maximum allowed file size in bytes (client-enforced via Content-Length header). Defaults to 10MB. Maximum: 100MB."),
68
+ }),
69
+ },
70
+ },
71
+ },
72
+ },
73
+ responses: {
74
+ 200: {
75
+ description: "Presigned URL generated",
76
+ content: { "application/json": { schema: z.object({ url: z.string(), key: z.string(), maxBytes: z.number().optional() }) } },
77
+ },
78
+ 400: {
79
+ description: "File type not allowed",
80
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
81
+ },
82
+ 501: {
83
+ description: "Not implemented by adapter",
84
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
85
+ },
86
+ },
87
+ });
88
+ router.openapi(presignRoute, async (c) => {
89
+ const adapter = getStorageAdapter();
90
+ if (!adapter?.presignPut) {
91
+ return c.json({ error: "Presigned URLs not supported by the configured storage adapter" }, 501);
92
+ }
93
+ const { filename, mimeType, expirySeconds, maxBytes } = c.req.valid("json");
94
+ if (mimeType && BLOCKED_MIME_TYPES.has(mimeType)) {
95
+ return c.json({ error: "File type not allowed." }, 400);
96
+ }
97
+ const userId = c.get("authUserId") ?? undefined;
98
+ const tenantId = c.get("tenantId") ?? undefined;
99
+ // Server-generates the key — client cannot control the storage path
100
+ const key = generateUploadKeyFromFilename(filename, { userId, tenantId });
101
+ const expiry = expirySeconds ?? (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
102
+ const url = await adapter.presignPut(key, { expirySeconds: expiry, mimeType });
103
+ // Register the upload for ownership tracking
104
+ await registerUpload({
105
+ key,
106
+ ownerUserId: userId,
107
+ tenantId,
108
+ mimeType,
109
+ bucket: c.get("uploadBucket") ?? undefined,
110
+ createdAt: Date.now(),
111
+ });
112
+ return c.json({ url, key, ...(maxBytes !== undefined ? { maxBytes } : {}) }, 200);
113
+ });
114
+ const presignGetRoute = createRoute({
115
+ method: "get",
116
+ path: `${basePath}/presign/:key{.+}`,
117
+ tags,
118
+ summary: "Generate presigned download URL",
119
+ request: {
120
+ params: z.object({ key: z.string() }),
121
+ query: z.object({
122
+ expiry: z.string().optional().describe("URL expiry in seconds (default: 3600)"),
123
+ }),
124
+ },
125
+ responses: {
126
+ 200: {
127
+ description: "Presigned download URL",
128
+ content: {
129
+ "application/json": {
130
+ schema: z.object({
131
+ url: z.string(),
132
+ expiresAt: z.number().describe("Unix timestamp (seconds) when the URL expires"),
133
+ }),
134
+ },
135
+ },
136
+ },
137
+ 403: {
138
+ description: "Forbidden — not the owner or unauthorized",
139
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
140
+ },
141
+ 404: {
142
+ description: "Key not found in upload registry",
143
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
144
+ },
145
+ 501: {
146
+ description: "Not implemented",
147
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
148
+ },
149
+ },
150
+ });
151
+ router.openapi(presignGetRoute, async (c) => {
152
+ const { key } = c.req.valid("param");
153
+ const { expiry: expiryStr } = c.req.valid("query");
154
+ const userId = c.get("authUserId");
155
+ const tenantId = c.get("tenantId");
156
+ const { allowed, notFound } = await checkUploadAccess("read", key, userId, tenantId, config);
157
+ if (notFound)
158
+ return c.json({ error: "Not found" }, 404);
159
+ if (!allowed)
160
+ return c.json({ error: "Forbidden" }, 403);
161
+ const expirySeconds = expiryStr ? parseInt(expiryStr, 10) : (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
162
+ const signingCfg = getSigningConfig();
163
+ if (signingCfg?.presignedUrls) {
164
+ const secret = getSigningSecret();
165
+ if (!secret)
166
+ return c.json({ error: "Signing secret not configured" }, 501);
167
+ const defaultExpiry = typeof signingCfg.presignedUrls === "object"
168
+ ? (signingCfg.presignedUrls.defaultExpiry ?? expirySeconds)
169
+ : expirySeconds;
170
+ const base = new URL(c.req.url);
171
+ base.pathname = `${basePath}/download/${key}`;
172
+ base.search = "";
173
+ const url = createPresignedUrl(base.toString(), key, { method: "GET", expiry: defaultExpiry }, secret);
174
+ const expiresAt = Math.floor(Date.now() / 1000) + defaultExpiry;
175
+ return c.json({ url, expiresAt }, 200);
176
+ }
177
+ // Fallback: adapter.presignGet (S3 only)
178
+ const adapter = getStorageAdapter();
179
+ if (!adapter?.presignGet) {
180
+ return c.json({ error: "Presigned download URLs not supported. Enable signing.presignedUrls or use an S3 adapter." }, 501);
181
+ }
182
+ const url = await adapter.presignGet(key, { expirySeconds });
183
+ const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds;
184
+ return c.json({ url, expiresAt }, 200);
185
+ });
186
+ const deleteRoute = createRoute({
187
+ method: "delete",
188
+ path: `${basePath}/:key{.+}`,
189
+ tags,
190
+ summary: "Delete an uploaded file",
191
+ request: {
192
+ params: z.object({ key: z.string() }),
193
+ },
194
+ responses: {
195
+ 204: { description: "Deleted" },
196
+ 403: {
197
+ description: "Forbidden — not the owner or unauthorized",
198
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
199
+ },
200
+ 404: {
201
+ description: "Key not found in upload registry",
202
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
203
+ },
204
+ 500: {
205
+ description: "No storage adapter configured",
206
+ content: { "application/json": { schema: z.object({ error: z.string() }) } },
207
+ },
208
+ },
209
+ });
210
+ router.openapi(deleteRoute, async (c) => {
211
+ const adapter = getStorageAdapter();
212
+ if (!adapter)
213
+ return c.json({ error: "No storage adapter configured" }, 500);
214
+ const { key } = c.req.valid("param");
215
+ const userId = c.get("authUserId");
216
+ const tenantId = c.get("tenantId");
217
+ const { allowed, notFound } = await checkUploadAccess("delete", key, userId, tenantId, config);
218
+ if (notFound)
219
+ return c.json({ error: "Not found" }, 404);
220
+ if (!allowed)
221
+ return c.json({ error: "Forbidden" }, 403);
222
+ await adapter.delete(key);
223
+ await deleteUploadRecord(key);
224
+ return c.body(null, 204);
225
+ });
226
+ return router;
227
+ };
package/dist/server.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
2
  import { type CreateAppConfig } from "./app";
3
3
  import { type SocketData } from "./ws/index";
4
+ import { type HeartbeatConfig } from "./lib/wsHeartbeat";
5
+ import { type WsMessageStore, type WsMessageDefaults } from "./lib/wsMessages";
4
6
  export interface WsConfig<T extends object = object> {
5
7
  /** Override or extend the default WebSocket handler */
6
8
  handler?: WebSocketHandler<SocketData<T>>;
@@ -18,6 +20,25 @@ export interface WsConfig<T extends object = object> {
18
20
  * Defaults to 65536 (64 KB).
19
21
  */
20
22
  maxMessageSize?: number;
23
+ /**
24
+ * Heartbeat / ping-pong keepalive. Set `true` for defaults (30s interval, 10s timeout)
25
+ * or provide an object to customize intervals.
26
+ */
27
+ heartbeat?: boolean | HeartbeatConfig;
28
+ /**
29
+ * Presence tracking. Set `true` for defaults or provide config.
30
+ * When enabled, `presence_join`/`presence_leave` events are broadcast to rooms.
31
+ */
32
+ presence?: boolean | {
33
+ broadcastEvents?: boolean;
34
+ };
35
+ /**
36
+ * Message persistence. Opt rooms in via `configureRoom()`.
37
+ */
38
+ persistence?: {
39
+ store?: WsMessageStore;
40
+ defaults?: WsMessageDefaults;
41
+ };
21
42
  }
22
43
  export interface CreateServerConfig<T extends object = object> extends CreateAppConfig {
23
44
  port?: number;
@@ -27,5 +48,10 @@ export interface CreateServerConfig<T extends object = object> extends CreateApp
27
48
  enableWorkers?: boolean;
28
49
  /** WebSocket configuration */
29
50
  ws?: WsConfig<T>;
51
+ /**
52
+ * Maximum request body size in bytes. Defaults to the upload config limit when present
53
+ * (maxFileSize * maxFiles), otherwise Bun's default (128 MB).
54
+ */
55
+ maxRequestBodySize?: number;
30
56
  }
31
57
  export declare const createServer: <T extends object = object>(config: CreateServerConfig<T>) => Promise<Server<SocketData<T>>>;
package/dist/server.js CHANGED
@@ -1,17 +1,44 @@
1
1
  import { createApp } from "./app";
2
2
  import { websocket as defaultWebsocket, createWsUpgradeHandler } from "./ws/index";
3
- import { setWsServer, handleRoomActions, cleanupSocket } from "./lib/ws";
3
+ import { setWsServer, handleRoomActions, cleanupSocket, setPresenceEnabled } from "./lib/ws";
4
+ import { registerSocket, deregisterSocket, handlePong, startHeartbeat, stopHeartbeat } from "./lib/wsHeartbeat";
5
+ import { trackSocket, untrackSocket } from "./lib/wsPresence";
6
+ import { setWsMessageStore, setWsMessageDefaults } from "./lib/wsMessages";
4
7
  import { log } from "./lib/logger";
5
8
  export const createServer = async (config) => {
6
9
  const app = await createApp(config);
7
10
  const port = Number(process.env.PORT ?? config.port ?? 3000);
8
11
  const { workersDir, enableWorkers = true, ws: wsConfig = {} } = config;
9
- const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe, maxMessageSize = 65_536 } = wsConfig;
12
+ // Compute maxRequestBodySize: explicit config wins, else derive from upload config
13
+ let maxRequestBodySize = config.maxRequestBodySize;
14
+ if (maxRequestBodySize === undefined && config.upload) {
15
+ const maxFileSize = config.upload.maxFileSize ?? 10 * 1024 * 1024;
16
+ const maxFiles = config.upload.maxFiles ?? 10;
17
+ maxRequestBodySize = maxFileSize * maxFiles;
18
+ }
19
+ const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe, maxMessageSize = 65_536, heartbeat: heartbeatConfig, presence: presenceConfig, persistence: persistenceConfig, } = wsConfig;
20
+ // Configure presence
21
+ if (presenceConfig)
22
+ setPresenceEnabled(true);
23
+ // Configure message persistence
24
+ if (persistenceConfig) {
25
+ if (persistenceConfig.store)
26
+ setWsMessageStore(persistenceConfig.store);
27
+ if (persistenceConfig.defaults)
28
+ setWsMessageDefaults(persistenceConfig.defaults);
29
+ }
10
30
  const defaultOpen = defaultWebsocket.open;
11
31
  const defaultClose = defaultWebsocket.close;
12
32
  const defaultDrain = defaultWebsocket.drain;
33
+ const heartbeatEnabled = !!heartbeatConfig;
13
34
  const ws = {
14
- open: userWs?.open ?? defaultOpen,
35
+ open(socket) {
36
+ if (heartbeatEnabled)
37
+ registerSocket(socket, socket.data.id);
38
+ if (presenceConfig)
39
+ trackSocket(socket.data.id, socket.data.userId);
40
+ (userWs?.open ?? defaultOpen)(socket);
41
+ },
15
42
  async message(socket, message) {
16
43
  const size = typeof message === "string" ? message.length : message.byteLength;
17
44
  if (size > maxMessageSize) {
@@ -26,10 +53,18 @@ export const createServer = async (config) => {
26
53
  }
27
54
  },
28
55
  close(socket, code, reason) {
56
+ if (heartbeatEnabled)
57
+ deregisterSocket(socket.data.id);
58
+ if (presenceConfig)
59
+ untrackSocket(socket.data.id);
29
60
  cleanupSocket(socket.data.id, socket.data.rooms);
30
61
  socket.data.rooms.clear();
31
62
  (userWs?.close ?? defaultClose)(socket, code, reason);
32
63
  },
64
+ pong(socket) {
65
+ if (heartbeatEnabled)
66
+ handlePong(socket.data.id);
67
+ },
33
68
  drain: userWs?.drain ?? defaultDrain,
34
69
  };
35
70
  let server;
@@ -42,12 +77,20 @@ export const createServer = async (config) => {
42
77
  },
43
78
  fetch: app.fetch,
44
79
  websocket: ws,
80
+ ...(maxRequestBodySize !== undefined ? { maxRequestBodySize } : {}),
45
81
  error(err) {
46
82
  console.error(err);
47
83
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
48
84
  },
49
85
  });
50
86
  setWsServer(server);
87
+ // Start heartbeat after server is ready
88
+ if (heartbeatEnabled)
89
+ startHeartbeat(heartbeatConfig);
90
+ // Graceful shutdown — stop heartbeat alongside existing cleanup
91
+ const gracefulShutdown = () => { stopHeartbeat(); };
92
+ process.on("SIGTERM", gracefulShutdown);
93
+ process.on("SIGINT", gracefulShutdown);
51
94
  if (enableWorkers && workersDir) {
52
95
  const glob = new Bun.Glob("**/*.ts");
53
96
  for await (const file of glob.scan({ cwd: workersDir })) {
@@ -15,6 +15,7 @@ export interface AuthResult {
15
15
  export declare const createSessionForUser: (userId: string, metadata?: SessionMetadata) => Promise<{
16
16
  token: string;
17
17
  refreshToken?: string;
18
+ sessionId: string;
18
19
  }>;
19
20
  export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
20
21
  export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
@@ -25,3 +26,4 @@ export declare const refresh: (refreshTokenValue: string) => Promise<{
25
26
  }>;
26
27
  export declare const deleteAccount: (userId: string, password?: string) => Promise<void>;
27
28
  export declare const logout: (token: string | null) => Promise<void>;
29
+ export declare const passkeyLogin: (passkeyToken: string, assertionResponse: any, metadata?: SessionMetadata) => Promise<AuthResult>;
@@ -2,14 +2,16 @@ import { getAuthAdapter } from "../lib/authAdapter";
2
2
  import { HttpError } from "../lib/HttpError";
3
3
  import { signToken, verifyToken } from "../lib/jwt";
4
4
  import { createSession, deleteSession, getActiveSessionCount, evictOldestSession, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "../lib/session";
5
- import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig, getMfaWebAuthnConfig } from "../lib/appConfig";
5
+ import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getMfaWebAuthnPasskeyMfaBypass } from "../lib/appConfig";
6
+ import { getSuspended } from "../lib/suspension";
6
7
  import { createVerificationToken } from "../lib/emailVerification";
7
8
  import { createMfaChallenge } from "../lib/mfaChallenge";
8
9
  import { generateEmailOtpCode, generateWebAuthnAuthenticationOptions } from "./mfa";
10
+ import { emitSecurityEvent } from "../lib/securityEvents";
9
11
  async function createSessionWithRefreshToken(userId, sessionId, metadata) {
10
12
  const rtConfig = getRefreshTokenConfig();
11
13
  const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
12
- const token = await signToken(userId, sessionId, expirySeconds);
14
+ const token = await signToken({ sub: userId, sid: sessionId }, expirySeconds);
13
15
  while (await getActiveSessionCount(userId) >= getMaxSessions()) {
14
16
  await evictOldestSession(userId);
15
17
  }
@@ -27,25 +29,32 @@ export const createSessionForUser = async (userId, metadata) => {
27
29
  return createSessionWithRefreshToken(userId, sessionId, metadata);
28
30
  };
29
31
  export const register = async (identifier, password, metadata) => {
30
- const hashed = await Bun.password.hash(password);
31
- const adapter = getAuthAdapter();
32
- const user = await adapter.create(identifier, hashed);
33
- const role = getDefaultRole();
34
- if (role)
35
- await adapter.setRoles(user.id, [role]);
36
- const sessionId = crypto.randomUUID();
37
- const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
38
- const evConfig = getEmailVerificationConfig();
39
- if (evConfig && getPrimaryField() === "email") {
40
- try {
41
- const verificationToken = await createVerificationToken(user.id, identifier);
42
- await evConfig.onSend(identifier, verificationToken);
43
- }
44
- catch (e) {
45
- console.error("[email-verification] Failed to send verification email:", e);
32
+ try {
33
+ const hashed = await Bun.password.hash(password);
34
+ const adapter = getAuthAdapter();
35
+ const user = await adapter.create(identifier, hashed);
36
+ const role = getDefaultRole();
37
+ if (role)
38
+ await adapter.setRoles(user.id, [role]);
39
+ const sessionId = crypto.randomUUID();
40
+ const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
41
+ const evConfig = getEmailVerificationConfig();
42
+ if (evConfig && getPrimaryField() === "email") {
43
+ try {
44
+ const verificationToken = await createVerificationToken(user.id, identifier);
45
+ await evConfig.onSend(identifier, verificationToken);
46
+ }
47
+ catch (e) {
48
+ console.error("[email-verification] Failed to send verification email:", e);
49
+ }
46
50
  }
51
+ emitSecurityEvent({ eventType: "auth.register.success", severity: "info", timestamp: new Date().toISOString(), userId: user.id });
52
+ return { token, userId: user.id, email: identifier, refreshToken };
53
+ }
54
+ catch (err) {
55
+ emitSecurityEvent({ eventType: "auth.register.failure", severity: "warn", timestamp: new Date().toISOString() });
56
+ throw err;
47
57
  }
48
- return { token, userId: user.id, email: identifier, refreshToken };
49
58
  };
50
59
  // Pre-computed dummy hash so non-existent-user login takes the same time as wrong-password login
51
60
  const DUMMY_HASH = await Bun.password.hash("dummy-timing-safe-placeholder");
@@ -57,8 +66,15 @@ export const login = async (identifier, password, metadata) => {
57
66
  const hashToVerify = user?.passwordHash ?? DUMMY_HASH;
58
67
  const passwordValid = await Bun.password.verify(password, hashToVerify);
59
68
  if (!user || !passwordValid) {
69
+ emitSecurityEvent({ eventType: "auth.login.failure", severity: "warn", timestamp: new Date().toISOString(), meta: { identifier } });
60
70
  throw new HttpError(401, "Invalid credentials");
61
71
  }
72
+ // Check suspension
73
+ const suspensionStatus = await getSuspended(user.id);
74
+ if (suspensionStatus.suspended) {
75
+ emitSecurityEvent({ eventType: "auth.login.blocked", severity: "critical", timestamp: new Date().toISOString(), meta: { reason: "suspended" } });
76
+ throw new HttpError(403, "Account suspended", "ACCOUNT_SUSPENDED");
77
+ }
62
78
  // Check email verification before MFA to avoid leaking MFA status to unverified users
63
79
  const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
64
80
  const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
@@ -100,6 +116,7 @@ export const login = async (identifier, password, metadata) => {
100
116
  }
101
117
  const sessionId = crypto.randomUUID();
102
118
  const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
119
+ emitSecurityEvent({ eventType: "auth.login.success", severity: "info", timestamp: new Date().toISOString(), userId: user.id });
103
120
  if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
104
121
  const verified = await adapter.getEmailVerified(user.id);
105
122
  return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked, refreshToken };
@@ -115,12 +132,12 @@ export const refresh = async (refreshTokenValue) => {
115
132
  // If the returned newRefreshToken differs from what was sent, we're in a grace window replay.
116
133
  // Return the current tokens without rotating again.
117
134
  if (newRefreshToken !== refreshTokenValue) {
118
- const accessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
135
+ const accessToken = await signToken({ sub: userId, sid: sessionId }, getAccessTokenExpiry());
119
136
  return { token: accessToken, refreshToken: newRefreshToken, userId };
120
137
  }
121
138
  // Normal rotation: generate new refresh + access tokens
122
139
  const newRT = crypto.randomUUID();
123
- const newAccessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
140
+ const newAccessToken = await signToken({ sub: userId, sid: sessionId }, getAccessTokenExpiry());
124
141
  await rotateRefreshToken(sessionId, newRT, newAccessToken);
125
142
  return { token: newAccessToken, refreshToken: newRT, userId };
126
143
  };
@@ -148,12 +165,74 @@ export const deleteAccount = async (userId, password) => {
148
165
  await deleteUserSessions(userId);
149
166
  // Delete the user
150
167
  await adapter.deleteUser(userId);
168
+ emitSecurityEvent({ eventType: "auth.account.deleted", severity: "warn", timestamp: new Date().toISOString(), userId });
151
169
  };
152
170
  export const logout = async (token) => {
153
171
  if (token) {
154
172
  const payload = await verifyToken(token);
155
173
  const sessionId = payload.sid;
156
- if (sessionId)
174
+ if (sessionId) {
157
175
  await deleteSession(sessionId);
176
+ emitSecurityEvent({ eventType: "auth.logout", severity: "info", timestamp: new Date().toISOString(), sessionId });
177
+ }
178
+ }
179
+ };
180
+ export const passkeyLogin = async (passkeyToken, assertionResponse, metadata) => {
181
+ const adapter = getAuthAdapter();
182
+ if (!adapter.findUserByWebAuthnCredentialId || !adapter.getWebAuthnCredentials) {
183
+ throw new HttpError(501, "Auth adapter does not support passkey login");
184
+ }
185
+ const { consumePasskeyLoginChallenge } = await import("../lib/mfaChallenge");
186
+ const challengeData = await consumePasskeyLoginChallenge(passkeyToken);
187
+ if (!challengeData) {
188
+ throw new HttpError(401, "Invalid or expired passkey token");
189
+ }
190
+ const credentialId = assertionResponse?.id;
191
+ if (!credentialId) {
192
+ throw new HttpError(401, "Invalid assertion response");
193
+ }
194
+ const userId = await adapter.findUserByWebAuthnCredentialId(credentialId);
195
+ if (!userId) {
196
+ throw new HttpError(401, "Invalid credentials");
197
+ }
198
+ const { verifyWebAuthn } = await import("./mfa");
199
+ const verified = await verifyWebAuthn(userId, assertionResponse, challengeData.webauthnChallenge);
200
+ if (!verified) {
201
+ throw new HttpError(401, "WebAuthn verification failed");
202
+ }
203
+ // Check suspension
204
+ const suspensionStatus = await getSuspended(userId);
205
+ if (suspensionStatus.suspended) {
206
+ throw new HttpError(403, "Account suspended", "ACCOUNT_SUSPENDED");
207
+ }
208
+ // passkeyMfaBypass=true (default): passkey with userVerification=required satisfies both factors
209
+ const mfaBypass = getMfaWebAuthnPasskeyMfaBypass();
210
+ if (!mfaBypass && getMfaConfig() && adapter.isMfaEnabled && await adapter.isMfaEnabled(userId)) {
211
+ const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : ["totp"];
212
+ let emailOtpHash;
213
+ const emailOtpConfig = getMfaEmailOtpConfig();
214
+ if (methods.includes("emailOtp") && emailOtpConfig) {
215
+ const { generateEmailOtpCode } = await import("./mfa");
216
+ const { code, hash } = generateEmailOtpCode();
217
+ emailOtpHash = hash;
218
+ const fullUser = adapter.getUser ? await adapter.getUser(userId) : null;
219
+ if (fullUser?.email)
220
+ await emailOtpConfig.onSend(fullUser.email, code);
221
+ }
222
+ let webauthnChallenge2;
223
+ let webauthnOptions;
224
+ if (methods.includes("webauthn") && getMfaWebAuthnConfig()) {
225
+ const { generateWebAuthnAuthenticationOptions } = await import("./mfa");
226
+ const result = await generateWebAuthnAuthenticationOptions(userId);
227
+ if (result) {
228
+ webauthnChallenge2 = result.challenge;
229
+ webauthnOptions = result.options;
230
+ }
231
+ }
232
+ const mfaToken = await createMfaChallenge(userId, { emailOtpHash, webauthnChallenge: webauthnChallenge2 });
233
+ return { token: "", userId, mfaRequired: true, mfaToken, mfaMethods: methods, webauthnOptions };
158
234
  }
235
+ const { token, refreshToken } = await createSessionForUser(userId, metadata);
236
+ const fullUser = adapter.getUser ? await adapter.getUser(userId) : null;
237
+ return { token, userId, email: fullUser?.email, refreshToken };
159
238
  };
@@ -343,8 +343,8 @@ export const initiateWebAuthnRegistration = async (userId) => {
343
343
  })),
344
344
  authenticatorSelection: {
345
345
  authenticatorAttachment: config.authenticatorAttachment,
346
- userVerification: config.userVerification ?? "preferred",
347
- residentKey: "preferred",
346
+ userVerification: config.userVerification ?? "required",
347
+ residentKey: "required",
348
348
  },
349
349
  timeout: config.timeout ?? 60000,
350
350
  });
package/dist/ws/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { verifyToken } from "../lib/jwt";
2
2
  import { getSession } from "../lib/session";
3
3
  import { COOKIE_TOKEN } from "../lib/constants";
4
+ import { trackSocket, untrackSocket } from "../lib/wsPresence";
5
+ import { timingSafeEqual } from "../lib/crypto";
4
6
  export const createWsUpgradeHandler = (server) => async (req) => {
5
7
  let userId = null;
6
8
  try {
@@ -11,7 +13,7 @@ export const createWsUpgradeHandler = (server) => async (req) => {
11
13
  const sessionId = payload.sid;
12
14
  if (sessionId) {
13
15
  const stored = await getSession(sessionId);
14
- if (stored === token)
16
+ if (timingSafeEqual(stored ?? "", token))
15
17
  userId = payload.sub;
16
18
  }
17
19
  }
@@ -22,6 +24,7 @@ export const createWsUpgradeHandler = (server) => async (req) => {
22
24
  };
23
25
  export const websocket = {
24
26
  open(ws) {
27
+ trackSocket(ws.data.id, ws.data.userId);
25
28
  console.log(`[ws] connected: ${ws.data.id}`);
26
29
  ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
27
30
  },
@@ -30,6 +33,7 @@ export const websocket = {
30
33
  // Override ws.handler.message in WsConfig for custom message handling.
31
34
  },
32
35
  close(ws) {
36
+ untrackSocket(ws.data.id);
33
37
  console.log(`[ws] disconnected: ${ws.data.id}`);
34
38
  },
35
39
  };