@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,330 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Types
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// State
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const DEFAULT_MAX_COUNT = 100;
|
|
8
|
+
const DEFAULT_TTL_SECONDS = 86_400; // 24 hours
|
|
9
|
+
let _store = "memory";
|
|
10
|
+
let _defaults = { maxCount: DEFAULT_MAX_COUNT, ttlSeconds: DEFAULT_TTL_SECONDS };
|
|
11
|
+
const _roomConfigs = new Map();
|
|
12
|
+
// Memory store
|
|
13
|
+
const _memoryMessages = new Map();
|
|
14
|
+
// SQLite trim counters — tracks inserts since last trim per room.
|
|
15
|
+
// Resets on server restart; rooms may temporarily exceed maxCount by up to trimInterval rows.
|
|
16
|
+
// This is a soft cap by design to avoid a DELETE on every INSERT under high throughput.
|
|
17
|
+
const _sqliteTrimCounters = new Map();
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Configuration
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const setWsMessageStore = (store) => { _store = store; };
|
|
22
|
+
export const setWsMessageDefaults = (defaults) => {
|
|
23
|
+
_defaults = {
|
|
24
|
+
maxCount: defaults.maxCount ?? DEFAULT_MAX_COUNT,
|
|
25
|
+
ttlSeconds: defaults.ttlSeconds ?? DEFAULT_TTL_SECONDS,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Opt a room into message persistence. */
|
|
29
|
+
export const configureRoom = (room, options) => {
|
|
30
|
+
if (!options.persist) {
|
|
31
|
+
_roomConfigs.delete(room);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
_roomConfigs.set(room, {
|
|
35
|
+
maxCount: options.maxCount ?? _defaults.maxCount,
|
|
36
|
+
ttlSeconds: options.ttlSeconds ?? _defaults.ttlSeconds,
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* Persist a message to a room. Returns null if room is not configured for persistence.
|
|
44
|
+
* On store errors, logs a warning and returns null (non-blocking).
|
|
45
|
+
*/
|
|
46
|
+
export const persistMessage = async (room, data) => {
|
|
47
|
+
const config = _roomConfigs.get(room);
|
|
48
|
+
if (!config)
|
|
49
|
+
return null;
|
|
50
|
+
const message = {
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
room,
|
|
53
|
+
senderId: data.senderId ?? null,
|
|
54
|
+
payload: data.payload,
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
switch (_store) {
|
|
59
|
+
case "memory":
|
|
60
|
+
return memoryPersist(message, config);
|
|
61
|
+
case "redis":
|
|
62
|
+
return await redisPersist(message, config);
|
|
63
|
+
case "mongo":
|
|
64
|
+
return await mongoPersist(message, config);
|
|
65
|
+
case "sqlite":
|
|
66
|
+
return sqlitePersist(message, config);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.warn(`[wsMessages] failed to persist message to ${room}:`, err);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Get message history for a room.
|
|
76
|
+
* Cursor-based pagination using message `id` as cursor.
|
|
77
|
+
*/
|
|
78
|
+
export const getMessageHistory = async (room, opts) => {
|
|
79
|
+
const limit = opts?.limit ?? 50;
|
|
80
|
+
switch (_store) {
|
|
81
|
+
case "memory":
|
|
82
|
+
return memoryGetHistory(room, limit, opts?.before, opts?.after);
|
|
83
|
+
case "redis":
|
|
84
|
+
return await redisGetHistory(room, limit, opts?.before, opts?.after);
|
|
85
|
+
case "mongo":
|
|
86
|
+
return await mongoGetHistory(room, limit, opts?.before, opts?.after);
|
|
87
|
+
case "sqlite":
|
|
88
|
+
return sqliteGetHistory(room, limit, opts?.before, opts?.after);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/** Reset memory store. Useful for test isolation. */
|
|
92
|
+
export const clearWsMessageMemoryStore = () => {
|
|
93
|
+
_memoryMessages.clear();
|
|
94
|
+
_roomConfigs.clear();
|
|
95
|
+
_sqliteTrimCounters.clear();
|
|
96
|
+
};
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Memory backend
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
function memoryPersist(msg, config) {
|
|
101
|
+
if (!_memoryMessages.has(msg.room))
|
|
102
|
+
_memoryMessages.set(msg.room, []);
|
|
103
|
+
const msgs = _memoryMessages.get(msg.room);
|
|
104
|
+
msgs.push(msg);
|
|
105
|
+
// Trim to maxCount
|
|
106
|
+
if (msgs.length > config.maxCount) {
|
|
107
|
+
msgs.splice(0, msgs.length - config.maxCount);
|
|
108
|
+
}
|
|
109
|
+
return msg;
|
|
110
|
+
}
|
|
111
|
+
function memoryGetHistory(room, limit, before, after) {
|
|
112
|
+
const msgs = _memoryMessages.get(room) ?? [];
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const config = _roomConfigs.get(room);
|
|
115
|
+
const ttlMs = (config?.ttlSeconds ?? _defaults.ttlSeconds) * 1000;
|
|
116
|
+
// Filter expired
|
|
117
|
+
let filtered = msgs.filter((m) => now - m.createdAt < ttlMs);
|
|
118
|
+
if (before) {
|
|
119
|
+
const cursorIdx = filtered.findIndex((m) => m.id === before);
|
|
120
|
+
if (cursorIdx > 0)
|
|
121
|
+
filtered = filtered.slice(0, cursorIdx);
|
|
122
|
+
}
|
|
123
|
+
else if (after) {
|
|
124
|
+
const cursorIdx = filtered.findIndex((m) => m.id === after);
|
|
125
|
+
if (cursorIdx >= 0)
|
|
126
|
+
filtered = filtered.slice(cursorIdx + 1);
|
|
127
|
+
}
|
|
128
|
+
// Return last N messages (most recent)
|
|
129
|
+
return filtered.slice(-limit);
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Redis backend
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
async function redisPersist(msg, config) {
|
|
135
|
+
const { getRedis } = await import("./redis");
|
|
136
|
+
const redis = getRedis();
|
|
137
|
+
const key = `wsmsg:${msg.room}`;
|
|
138
|
+
const serialized = JSON.stringify(msg);
|
|
139
|
+
await redis.lpush(key, serialized);
|
|
140
|
+
await redis.ltrim(key, 0, config.maxCount - 1);
|
|
141
|
+
await redis.expire(key, config.ttlSeconds);
|
|
142
|
+
return msg;
|
|
143
|
+
}
|
|
144
|
+
async function redisGetHistory(room, limit, before, after) {
|
|
145
|
+
const { getRedis } = await import("./redis");
|
|
146
|
+
const redis = getRedis();
|
|
147
|
+
const key = `wsmsg:${room}`;
|
|
148
|
+
// Redis list is newest-first (LPUSH)
|
|
149
|
+
const raw = await redis.lrange(key, 0, -1);
|
|
150
|
+
let msgs = raw.map((s) => JSON.parse(s)).reverse(); // oldest-first
|
|
151
|
+
if (before) {
|
|
152
|
+
const idx = msgs.findIndex((m) => m.id === before);
|
|
153
|
+
if (idx > 0)
|
|
154
|
+
msgs = msgs.slice(0, idx);
|
|
155
|
+
}
|
|
156
|
+
else if (after) {
|
|
157
|
+
const idx = msgs.findIndex((m) => m.id === after);
|
|
158
|
+
if (idx >= 0)
|
|
159
|
+
msgs = msgs.slice(idx + 1);
|
|
160
|
+
}
|
|
161
|
+
return msgs.slice(-limit);
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// MongoDB backend
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
let _mongoModel = null;
|
|
167
|
+
async function getWsMessageModel() {
|
|
168
|
+
if (_mongoModel)
|
|
169
|
+
return _mongoModel;
|
|
170
|
+
const { authConnection } = await import("./mongo");
|
|
171
|
+
const { default: mongoose } = await import("mongoose");
|
|
172
|
+
const schema = new mongoose.Schema({
|
|
173
|
+
_id: { type: String, default: () => crypto.randomUUID() },
|
|
174
|
+
room: { type: String, required: true, index: true },
|
|
175
|
+
senderId: { type: String, default: null },
|
|
176
|
+
payload: { type: mongoose.Schema.Types.Mixed },
|
|
177
|
+
createdAt: { type: Number, required: true, index: true },
|
|
178
|
+
}, { collection: "ws_messages" });
|
|
179
|
+
schema.index({ room: 1, createdAt: 1, _id: 1 });
|
|
180
|
+
// TTL index — Mongo's TTL monitor runs ~every 60s, so messages may briefly
|
|
181
|
+
// outlive their TTL. Acceptable for chat history.
|
|
182
|
+
schema.index({ createdAt: 1 }, { expireAfterSeconds: DEFAULT_TTL_SECONDS });
|
|
183
|
+
_mongoModel = authConnection.model("WsMessage", schema);
|
|
184
|
+
return _mongoModel;
|
|
185
|
+
}
|
|
186
|
+
async function mongoPersist(msg, config) {
|
|
187
|
+
const Model = await getWsMessageModel();
|
|
188
|
+
await Model.create({
|
|
189
|
+
_id: msg.id,
|
|
190
|
+
room: msg.room,
|
|
191
|
+
senderId: msg.senderId,
|
|
192
|
+
payload: msg.payload,
|
|
193
|
+
createdAt: msg.createdAt,
|
|
194
|
+
});
|
|
195
|
+
// Trim to maxCount
|
|
196
|
+
const count = await Model.countDocuments({ room: msg.room });
|
|
197
|
+
if (count > config.maxCount) {
|
|
198
|
+
const oldest = await Model.find({ room: msg.room })
|
|
199
|
+
.sort({ createdAt: 1, _id: 1 })
|
|
200
|
+
.limit(count - config.maxCount)
|
|
201
|
+
.select("_id");
|
|
202
|
+
if (oldest.length > 0) {
|
|
203
|
+
await Model.deleteMany({ _id: { $in: oldest.map((d) => d._id) } });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return msg;
|
|
207
|
+
}
|
|
208
|
+
async function mongoGetHistory(room, limit, before, after) {
|
|
209
|
+
const Model = await getWsMessageModel();
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
|
+
const filter = { room };
|
|
212
|
+
if (before) {
|
|
213
|
+
const cursor = await Model.findById(before).lean();
|
|
214
|
+
if (cursor) {
|
|
215
|
+
filter.$or = [
|
|
216
|
+
{ createdAt: { $lt: cursor.createdAt } },
|
|
217
|
+
{ createdAt: cursor.createdAt, _id: { $lt: before } },
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (after) {
|
|
222
|
+
const cursor = await Model.findById(after).lean();
|
|
223
|
+
if (cursor) {
|
|
224
|
+
filter.$or = [
|
|
225
|
+
{ createdAt: { $gt: cursor.createdAt } },
|
|
226
|
+
{ createdAt: cursor.createdAt, _id: { $gt: after } },
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const docs = await Model.find(filter)
|
|
231
|
+
.sort({ createdAt: -1, _id: -1 })
|
|
232
|
+
.limit(limit)
|
|
233
|
+
.lean();
|
|
234
|
+
return docs.reverse().map((d) => ({
|
|
235
|
+
id: d._id,
|
|
236
|
+
room: d.room,
|
|
237
|
+
senderId: d.senderId,
|
|
238
|
+
payload: d.payload,
|
|
239
|
+
createdAt: d.createdAt,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// SQLite backend
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
function getSqliteDb() {
|
|
246
|
+
// Re-use the sqlite DB set via setSqliteDb
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
248
|
+
const { getSqliteDatabase } = require("../adapters/sqliteAuth");
|
|
249
|
+
return getSqliteDatabase();
|
|
250
|
+
}
|
|
251
|
+
let _sqliteInitialized = false;
|
|
252
|
+
function ensureSqliteTable() {
|
|
253
|
+
if (_sqliteInitialized)
|
|
254
|
+
return;
|
|
255
|
+
const db = getSqliteDb();
|
|
256
|
+
db.run(`
|
|
257
|
+
CREATE TABLE IF NOT EXISTS ws_messages (
|
|
258
|
+
id TEXT PRIMARY KEY,
|
|
259
|
+
room TEXT NOT NULL,
|
|
260
|
+
sender_id TEXT,
|
|
261
|
+
payload TEXT,
|
|
262
|
+
created_at INTEGER NOT NULL
|
|
263
|
+
)
|
|
264
|
+
`);
|
|
265
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_ws_messages_room_created ON ws_messages (room, created_at, id)`);
|
|
266
|
+
_sqliteInitialized = true;
|
|
267
|
+
}
|
|
268
|
+
function sqlitePersist(msg, config) {
|
|
269
|
+
ensureSqliteTable();
|
|
270
|
+
const db = getSqliteDb();
|
|
271
|
+
db.run(`INSERT INTO ws_messages (id, room, sender_id, payload, created_at) VALUES (?, ?, ?, ?, ?)`, [msg.id, msg.room, msg.senderId, JSON.stringify(msg.payload), msg.createdAt]);
|
|
272
|
+
// Modulo-based trimming to avoid DELETE on every INSERT.
|
|
273
|
+
const counter = (_sqliteTrimCounters.get(msg.room) ?? 0) + 1;
|
|
274
|
+
const trimInterval = Math.max(10, Math.floor(config.maxCount * 0.1));
|
|
275
|
+
_sqliteTrimCounters.set(msg.room, counter);
|
|
276
|
+
if (counter >= trimInterval) {
|
|
277
|
+
_sqliteTrimCounters.set(msg.room, 0);
|
|
278
|
+
// Delete oldest beyond maxCount
|
|
279
|
+
db.run(`DELETE FROM ws_messages WHERE room = ? AND id NOT IN (
|
|
280
|
+
SELECT id FROM ws_messages WHERE room = ? ORDER BY created_at DESC, id DESC LIMIT ?
|
|
281
|
+
)`, [msg.room, msg.room, config.maxCount]);
|
|
282
|
+
// TTL cleanup
|
|
283
|
+
const ttlMs = config.ttlSeconds * 1000;
|
|
284
|
+
db.run(`DELETE FROM ws_messages WHERE room = ? AND created_at < ?`, [msg.room, Date.now() - ttlMs]);
|
|
285
|
+
}
|
|
286
|
+
return msg;
|
|
287
|
+
}
|
|
288
|
+
function sqliteGetHistory(room, limit, before, after) {
|
|
289
|
+
ensureSqliteTable();
|
|
290
|
+
const db = getSqliteDb();
|
|
291
|
+
let sql;
|
|
292
|
+
let params;
|
|
293
|
+
if (before) {
|
|
294
|
+
sql = `
|
|
295
|
+
SELECT * FROM ws_messages
|
|
296
|
+
WHERE room = ? AND (created_at, id) < (
|
|
297
|
+
(SELECT created_at FROM ws_messages WHERE id = ?),
|
|
298
|
+
?
|
|
299
|
+
)
|
|
300
|
+
ORDER BY created_at DESC, id DESC LIMIT ?
|
|
301
|
+
`;
|
|
302
|
+
params = [room, before, before, limit];
|
|
303
|
+
}
|
|
304
|
+
else if (after) {
|
|
305
|
+
sql = `
|
|
306
|
+
SELECT * FROM ws_messages
|
|
307
|
+
WHERE room = ? AND (created_at, id) > (
|
|
308
|
+
(SELECT created_at FROM ws_messages WHERE id = ?),
|
|
309
|
+
?
|
|
310
|
+
)
|
|
311
|
+
ORDER BY created_at ASC, id ASC LIMIT ?
|
|
312
|
+
`;
|
|
313
|
+
params = [room, after, after, limit];
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
sql = `SELECT * FROM ws_messages WHERE room = ? ORDER BY created_at DESC, id DESC LIMIT ?`;
|
|
317
|
+
params = [room, limit];
|
|
318
|
+
}
|
|
319
|
+
const rows = db.query(sql).all(...params);
|
|
320
|
+
// Normalize to oldest-first
|
|
321
|
+
if (!after)
|
|
322
|
+
rows.reverse();
|
|
323
|
+
return rows.map((r) => ({
|
|
324
|
+
id: r.id,
|
|
325
|
+
room: r.room,
|
|
326
|
+
senderId: r.sender_id,
|
|
327
|
+
payload: JSON.parse(r.payload),
|
|
328
|
+
createdAt: r.created_at,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Track an authenticated socket. Skips if userId is null/undefined. */
|
|
2
|
+
export declare const trackSocket: (socketId: string, userId: string | null) => void;
|
|
3
|
+
/** Untrack a socket on disconnect. */
|
|
4
|
+
export declare const untrackSocket: (socketId: string) => void;
|
|
5
|
+
/** Called when a socket subscribes to a room. Returns join info or null if unauthenticated. */
|
|
6
|
+
export declare const addPresence: (socketId: string, room: string) => {
|
|
7
|
+
userId: string;
|
|
8
|
+
isNewUser: boolean;
|
|
9
|
+
} | null;
|
|
10
|
+
/** Called when a socket unsubscribes from a room. Returns leave info or null if unauthenticated. */
|
|
11
|
+
export declare const removePresence: (socketId: string, room: string) => {
|
|
12
|
+
userId: string;
|
|
13
|
+
isLastSocket: boolean;
|
|
14
|
+
} | null;
|
|
15
|
+
/** Called on disconnect — cleans up all rooms for a socket. Returns rooms where user fully departed. */
|
|
16
|
+
export declare const cleanupPresence: (socketId: string, rooms: Set<string>) => Array<{
|
|
17
|
+
room: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** Deduplicated userIds present in a room. */
|
|
21
|
+
export declare const getRoomPresence: (room: string) => string[];
|
|
22
|
+
/** Rooms where a user is present. */
|
|
23
|
+
export declare const getUserPresence: (userId: string) => string[];
|
|
24
|
+
/** Reset all presence state. Useful for test isolation. */
|
|
25
|
+
export declare const clearPresenceStore: () => void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// State (in-memory only)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/** socketId → userId (authenticated sockets only) */
|
|
5
|
+
const _socketUsers = new Map();
|
|
6
|
+
/** room → userId → Set<socketId> (multi-tab aware) */
|
|
7
|
+
const _roomPresence = new Map();
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Public API
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** Track an authenticated socket. Skips if userId is null/undefined. */
|
|
12
|
+
export const trackSocket = (socketId, userId) => {
|
|
13
|
+
if (!userId)
|
|
14
|
+
return;
|
|
15
|
+
_socketUsers.set(socketId, userId);
|
|
16
|
+
};
|
|
17
|
+
/** Untrack a socket on disconnect. */
|
|
18
|
+
export const untrackSocket = (socketId) => {
|
|
19
|
+
_socketUsers.delete(socketId);
|
|
20
|
+
};
|
|
21
|
+
/** Called when a socket subscribes to a room. Returns join info or null if unauthenticated. */
|
|
22
|
+
export const addPresence = (socketId, room) => {
|
|
23
|
+
const userId = _socketUsers.get(socketId);
|
|
24
|
+
if (!userId)
|
|
25
|
+
return null;
|
|
26
|
+
if (!_roomPresence.has(room))
|
|
27
|
+
_roomPresence.set(room, new Map());
|
|
28
|
+
const roomMap = _roomPresence.get(room);
|
|
29
|
+
const isNewUser = !roomMap.has(userId) || roomMap.get(userId).size === 0;
|
|
30
|
+
if (!roomMap.has(userId))
|
|
31
|
+
roomMap.set(userId, new Set());
|
|
32
|
+
roomMap.get(userId).add(socketId);
|
|
33
|
+
return { userId, isNewUser };
|
|
34
|
+
};
|
|
35
|
+
/** Called when a socket unsubscribes from a room. Returns leave info or null if unauthenticated. */
|
|
36
|
+
export const removePresence = (socketId, room) => {
|
|
37
|
+
const userId = _socketUsers.get(socketId);
|
|
38
|
+
if (!userId)
|
|
39
|
+
return null;
|
|
40
|
+
const roomMap = _roomPresence.get(room);
|
|
41
|
+
if (!roomMap)
|
|
42
|
+
return null;
|
|
43
|
+
const sockets = roomMap.get(userId);
|
|
44
|
+
if (!sockets)
|
|
45
|
+
return null;
|
|
46
|
+
sockets.delete(socketId);
|
|
47
|
+
const isLastSocket = sockets.size === 0;
|
|
48
|
+
if (isLastSocket) {
|
|
49
|
+
roomMap.delete(userId);
|
|
50
|
+
if (roomMap.size === 0)
|
|
51
|
+
_roomPresence.delete(room);
|
|
52
|
+
}
|
|
53
|
+
return { userId, isLastSocket };
|
|
54
|
+
};
|
|
55
|
+
/** Called on disconnect — cleans up all rooms for a socket. Returns rooms where user fully departed. */
|
|
56
|
+
export const cleanupPresence = (socketId, rooms) => {
|
|
57
|
+
const userId = _socketUsers.get(socketId);
|
|
58
|
+
if (!userId)
|
|
59
|
+
return [];
|
|
60
|
+
const departed = [];
|
|
61
|
+
for (const room of rooms) {
|
|
62
|
+
const roomMap = _roomPresence.get(room);
|
|
63
|
+
if (!roomMap)
|
|
64
|
+
continue;
|
|
65
|
+
const sockets = roomMap.get(userId);
|
|
66
|
+
if (!sockets)
|
|
67
|
+
continue;
|
|
68
|
+
sockets.delete(socketId);
|
|
69
|
+
if (sockets.size === 0) {
|
|
70
|
+
roomMap.delete(userId);
|
|
71
|
+
if (roomMap.size === 0)
|
|
72
|
+
_roomPresence.delete(room);
|
|
73
|
+
departed.push({ room, userId });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return departed;
|
|
77
|
+
};
|
|
78
|
+
/** Deduplicated userIds present in a room. */
|
|
79
|
+
export const getRoomPresence = (room) => {
|
|
80
|
+
const roomMap = _roomPresence.get(room);
|
|
81
|
+
if (!roomMap)
|
|
82
|
+
return [];
|
|
83
|
+
return [...roomMap.keys()];
|
|
84
|
+
};
|
|
85
|
+
/** Rooms where a user is present. */
|
|
86
|
+
export const getUserPresence = (userId) => {
|
|
87
|
+
const rooms = [];
|
|
88
|
+
for (const [room, roomMap] of _roomPresence) {
|
|
89
|
+
const sockets = roomMap.get(userId);
|
|
90
|
+
if (sockets && sockets.size > 0)
|
|
91
|
+
rooms.push(room);
|
|
92
|
+
}
|
|
93
|
+
return rooms;
|
|
94
|
+
};
|
|
95
|
+
/** Reset all presence state. Useful for test isolation. */
|
|
96
|
+
export const clearPresenceStore = () => {
|
|
97
|
+
_socketUsers.clear();
|
|
98
|
+
_roomPresence.clear();
|
|
99
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MiddlewareHandler, Context } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import type { AuditLogEntry, AuditLogOptions } from "../lib/auditLog";
|
|
4
|
+
export interface AuditLogMiddlewareOptions extends AuditLogOptions {
|
|
5
|
+
exclude?: {
|
|
6
|
+
/** Skip logging for requests with these HTTP methods (e.g. `["GET", "HEAD"]`). */
|
|
7
|
+
methods?: string[];
|
|
8
|
+
/**
|
|
9
|
+
* Skip logging for requests whose path matches any entry.
|
|
10
|
+
* Note: if this array grows large, regex evaluation on every request adds up.
|
|
11
|
+
* For high-traffic exclusions, prefer string matching over regex.
|
|
12
|
+
*/
|
|
13
|
+
paths?: (string | RegExp)[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Called after the entry is built, before it is written to storage.
|
|
17
|
+
* Use to add semantic context: `action`, `resource`, `resourceId`, `meta`, or `expiresAt`.
|
|
18
|
+
* If this hook throws, the error is logged and the original entry is written as-is.
|
|
19
|
+
*/
|
|
20
|
+
onEntry?: (entry: AuditLogEntry, c: Context<AppEnv>) => AuditLogEntry | Promise<AuditLogEntry>;
|
|
21
|
+
}
|
|
22
|
+
export declare const auditLog: (options: AuditLogMiddlewareOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getClientIp } from "../lib/clientIp";
|
|
2
|
+
import { logAuditEntry } from "../lib/auditLog";
|
|
3
|
+
export const auditLog = (options) => async (c, next) => {
|
|
4
|
+
await next();
|
|
5
|
+
// Exclusion checks run after next() intentionally — c.res.status is only available
|
|
6
|
+
// after the route handler runs. The route still executes; we're only skipping the log write.
|
|
7
|
+
if (options.exclude?.methods?.includes(c.req.method))
|
|
8
|
+
return;
|
|
9
|
+
// Note: if exclude.paths grows large, regex evaluation on every request adds up.
|
|
10
|
+
// For high-traffic exclusions, prefer string matching over regex.
|
|
11
|
+
const path = c.req.path;
|
|
12
|
+
if (options.exclude?.paths?.some(p => typeof p === "string" ? p === path : p.test(path)))
|
|
13
|
+
return;
|
|
14
|
+
let entry = {
|
|
15
|
+
id: crypto.randomUUID(),
|
|
16
|
+
requestId: c.get("requestId") ?? undefined,
|
|
17
|
+
userId: c.get("authUserId") ?? null,
|
|
18
|
+
sessionId: c.get("sessionId") ?? null,
|
|
19
|
+
tenantId: c.get("tenantId") ?? null,
|
|
20
|
+
method: c.req.method,
|
|
21
|
+
path,
|
|
22
|
+
status: c.res.status,
|
|
23
|
+
ip: getClientIp(c),
|
|
24
|
+
userAgent: c.req.header("user-agent") ?? null,
|
|
25
|
+
createdAt: new Date().toISOString(),
|
|
26
|
+
};
|
|
27
|
+
if (options.onEntry) {
|
|
28
|
+
try {
|
|
29
|
+
entry = await options.onEntry(entry, c);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error("[auditLog] onEntry hook threw:", err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Fire-and-forget — never block the response; logAuditEntry also swallows errors internally
|
|
36
|
+
logAuditEntry(entry, options).catch(err => {
|
|
37
|
+
console.error("[auditLog] write failed:", err);
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from "../lib/crypto";
|
|
2
2
|
export const bearerAuth = async (c, next) => {
|
|
3
3
|
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
-
const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
|
|
4
|
+
const validToken = (isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV) ?? undefined;
|
|
5
5
|
const header = c.req.header("Authorization");
|
|
6
6
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
7
7
|
if (!token || !validToken || !timingSafeEqual(token, validToken)) {
|
|
@@ -104,7 +104,11 @@ async function storeDelPattern(store, fullPattern) {
|
|
|
104
104
|
if (store === "mongo") {
|
|
105
105
|
if (!isMongoReady())
|
|
106
106
|
return;
|
|
107
|
-
|
|
107
|
+
// Escape all regex metacharacters in the full pattern (including the cache:{appName}: prefix,
|
|
108
|
+
// which may itself contain dots or other metacharacters). Then restore * as a glob wildcard.
|
|
109
|
+
// Order matters: escape first, then replace the now-escaped \* with .* for glob semantics.
|
|
110
|
+
const escaped = fullPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
111
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*") + "$");
|
|
108
112
|
await getCacheModel().deleteMany({ key: regex });
|
|
109
113
|
return;
|
|
110
114
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import { type CaptchaConfig } from "../lib/captcha";
|
|
4
|
+
/**
|
|
5
|
+
* Middleware factory that verifies a CAPTCHA token from the request body.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* router.post("/contact", requireCaptcha({ provider: "turnstile", secretKey: "..." }), handler);
|
|
9
|
+
*/
|
|
10
|
+
export declare const requireCaptcha: (config?: CaptchaConfig) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { verifyCaptcha } from "../lib/captcha";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
import { getClientIp } from "../lib/clientIp";
|
|
4
|
+
/**
|
|
5
|
+
* Middleware factory that verifies a CAPTCHA token from the request body.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* router.post("/contact", requireCaptcha({ provider: "turnstile", secretKey: "..." }), handler);
|
|
9
|
+
*/
|
|
10
|
+
export const requireCaptcha = (config) => async (c, next) => {
|
|
11
|
+
// Get effective config: param takes precedence, then global config
|
|
12
|
+
const { getCaptchaConfig } = await import("../lib/appConfig");
|
|
13
|
+
const effectiveConfig = config ?? getCaptchaConfig();
|
|
14
|
+
if (!effectiveConfig) {
|
|
15
|
+
await next();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const tokenField = effectiveConfig.tokenField ?? "captcha-token";
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = await c.req.json();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
body = {};
|
|
25
|
+
}
|
|
26
|
+
const token = body[tokenField];
|
|
27
|
+
if (!token) {
|
|
28
|
+
throw new HttpError(400, "CAPTCHA token is required", "CAPTCHA_MISSING");
|
|
29
|
+
}
|
|
30
|
+
const ip = getClientIp(c) ?? undefined;
|
|
31
|
+
const result = await verifyCaptcha(token, effectiveConfig, ip);
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
throw new HttpError(400, "CAPTCHA verification failed", "CAPTCHA_FAILED");
|
|
34
|
+
}
|
|
35
|
+
await next();
|
|
36
|
+
};
|
package/dist/middleware/csrf.js
CHANGED
|
@@ -4,11 +4,15 @@ import { COOKIE_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "../lib/const
|
|
|
4
4
|
import { createHmac, randomBytes } from "crypto";
|
|
5
5
|
const isProd = process.env.NODE_ENV === "production";
|
|
6
6
|
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
-
|
|
7
|
+
let _csrfSecret = null;
|
|
8
|
+
function getCsrfSecret() {
|
|
9
|
+
if (_csrfSecret)
|
|
10
|
+
return _csrfSecret;
|
|
8
11
|
const secret = isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV;
|
|
9
12
|
if (!secret)
|
|
10
13
|
throw new Error("CSRF middleware requires JWT_SECRET_DEV/JWT_SECRET_PROD to be set");
|
|
11
|
-
|
|
14
|
+
_csrfSecret = secret;
|
|
15
|
+
return _csrfSecret;
|
|
12
16
|
}
|
|
13
17
|
function generateCsrfToken(secret) {
|
|
14
18
|
const token = randomBytes(32).toString("hex");
|
|
@@ -36,7 +40,7 @@ const csrfCookieOptions = {
|
|
|
36
40
|
* session fixation-adjacent attacks.
|
|
37
41
|
*/
|
|
38
42
|
export function refreshCsrfToken(c) {
|
|
39
|
-
const secret =
|
|
43
|
+
const secret = getCsrfSecret();
|
|
40
44
|
const token = generateCsrfToken(secret);
|
|
41
45
|
setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
|
|
42
46
|
}
|
|
@@ -53,12 +57,22 @@ export const csrfProtection = (options = {}) => {
|
|
|
53
57
|
if (allowedOrigins) {
|
|
54
58
|
const origins = Array.isArray(allowedOrigins) ? allowedOrigins : [allowedOrigins];
|
|
55
59
|
for (const o of origins) {
|
|
60
|
+
// "*" is intentionally excluded: validating against a wildcard would accept any origin,
|
|
61
|
+
// defeating the check. When CORS is open, origin validation is meaningless.
|
|
56
62
|
if (o !== "*")
|
|
57
63
|
originSet.add(o.replace(/\/$/, ""));
|
|
58
64
|
}
|
|
59
65
|
}
|
|
66
|
+
if (checkOrigin && originSet.size === 0) {
|
|
67
|
+
// Warn in all environments — this is a one-time startup message, not per-request noise,
|
|
68
|
+
// and a misconfigured production deployment should surface it.
|
|
69
|
+
console.warn("[bunshot] csrfProtection: checkOrigin is enabled but no specific allowed origins are " +
|
|
70
|
+
"configured (CORS is \"*\" or allowedOrigins is unset). Origin validation is disabled — " +
|
|
71
|
+
"only the HMAC double-submit cookie check is active. Set security.cors to specific " +
|
|
72
|
+
"origins to enable origin validation.");
|
|
73
|
+
}
|
|
60
74
|
return async (c, next) => {
|
|
61
|
-
const secret =
|
|
75
|
+
const secret = getCsrfSecret();
|
|
62
76
|
// Set CSRF cookie on every response if not already present
|
|
63
77
|
const existingCsrf = getCookie(c, COOKIE_CSRF_TOKEN);
|
|
64
78
|
if (!existingCsrf) {
|
|
@@ -6,7 +6,10 @@ export const errorHandler = async (req, next) => {
|
|
|
6
6
|
catch (err) {
|
|
7
7
|
console.error(err);
|
|
8
8
|
if (err instanceof HttpError) {
|
|
9
|
-
|
|
9
|
+
const body = { error: err.message };
|
|
10
|
+
if (err.code !== undefined)
|
|
11
|
+
body.code = err.code;
|
|
12
|
+
return Response.json(body, { status: err.status });
|
|
10
13
|
}
|
|
11
14
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
12
15
|
}
|