@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.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- 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
|
-
|
|
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
|
|
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 })) {
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -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>;
|
package/dist/services/auth.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
};
|
package/dist/services/mfa.js
CHANGED
|
@@ -343,8 +343,8 @@ export const initiateWebAuthnRegistration = async (userId) => {
|
|
|
343
343
|
})),
|
|
344
344
|
authenticatorSelection: {
|
|
345
345
|
authenticatorAttachment: config.authenticatorAttachment,
|
|
346
|
-
userVerification: config.userVerification ?? "
|
|
347
|
-
residentKey: "
|
|
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
|
|
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
|
};
|