@lastshotlabs/bunshot 0.0.1

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 (50) hide show
  1. package/CLAUDE.md +102 -0
  2. package/README.md +1458 -0
  3. package/bun.lock +170 -0
  4. package/package.json +47 -0
  5. package/src/adapters/memoryAuth.ts +240 -0
  6. package/src/adapters/mongoAuth.ts +91 -0
  7. package/src/adapters/sqliteAuth.ts +320 -0
  8. package/src/app.ts +368 -0
  9. package/src/cli.ts +265 -0
  10. package/src/index.ts +52 -0
  11. package/src/lib/HttpError.ts +5 -0
  12. package/src/lib/appConfig.ts +29 -0
  13. package/src/lib/authAdapter.ts +46 -0
  14. package/src/lib/authRateLimit.ts +104 -0
  15. package/src/lib/constants.ts +2 -0
  16. package/src/lib/context.ts +17 -0
  17. package/src/lib/emailVerification.ts +105 -0
  18. package/src/lib/fingerprint.ts +43 -0
  19. package/src/lib/jwt.ts +17 -0
  20. package/src/lib/logger.ts +9 -0
  21. package/src/lib/mongo.ts +70 -0
  22. package/src/lib/oauth.ts +114 -0
  23. package/src/lib/queue.ts +18 -0
  24. package/src/lib/redis.ts +45 -0
  25. package/src/lib/roles.ts +23 -0
  26. package/src/lib/session.ts +91 -0
  27. package/src/lib/validate.ts +14 -0
  28. package/src/lib/ws.ts +82 -0
  29. package/src/middleware/bearerAuth.ts +15 -0
  30. package/src/middleware/botProtection.ts +73 -0
  31. package/src/middleware/cacheResponse.ts +189 -0
  32. package/src/middleware/cors.ts +19 -0
  33. package/src/middleware/errorHandler.ts +14 -0
  34. package/src/middleware/identify.ts +36 -0
  35. package/src/middleware/index.ts +8 -0
  36. package/src/middleware/logger.ts +9 -0
  37. package/src/middleware/rateLimit.ts +37 -0
  38. package/src/middleware/requireRole.ts +42 -0
  39. package/src/middleware/requireVerifiedEmail.ts +31 -0
  40. package/src/middleware/userAuth.ts +9 -0
  41. package/src/models/AuthUser.ts +17 -0
  42. package/src/routes/auth.ts +245 -0
  43. package/src/routes/health.ts +27 -0
  44. package/src/routes/home.ts +21 -0
  45. package/src/routes/oauth.ts +174 -0
  46. package/src/schemas/auth.ts +14 -0
  47. package/src/server.ts +91 -0
  48. package/src/services/auth.ts +59 -0
  49. package/src/ws/index.ts +42 -0
  50. package/tsconfig.json +43 -0
package/src/lib/jwt.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+
3
+ const isProd = process.env.NODE_ENV === "production";
4
+ const secret = new TextEncoder().encode(
5
+ isProd ? process.env.JWT_SECRET_PROD! : process.env.JWT_SECRET_DEV!
6
+ );
7
+
8
+ export const signToken = async (userId: string): Promise<string> =>
9
+ new SignJWT({ sub: userId })
10
+ .setProtectedHeader({ alg: "HS256" })
11
+ .setExpirationTime("7d")
12
+ .sign(secret);
13
+
14
+ export const verifyToken = async (token: string) => {
15
+ const { payload } = await jwtVerify(token, secret);
16
+ return payload;
17
+ };
@@ -0,0 +1,9 @@
1
+ const isDev = process.env.NODE_ENV !== "production";
2
+ const verboseEnv = process.env.LOGGING_VERBOSE;
3
+
4
+ const verbose =
5
+ verboseEnv !== undefined ? verboseEnv === "true" : isDev;
6
+
7
+ export const log = (...args: unknown[]) => {
8
+ if (verbose) console.log(...args);
9
+ };
@@ -0,0 +1,70 @@
1
+ import mongoose from "mongoose";
2
+ import { log } from "./logger";
3
+
4
+ const isProd = process.env.NODE_ENV === "production";
5
+
6
+ function buildUri(user: string, password: string, host: string, db: string): string {
7
+ const [hostPart, queryPart] = host.split("?");
8
+ return `mongodb+srv://${user}:${password}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
9
+ }
10
+
11
+ /**
12
+ * Named connection used exclusively for auth data (AuthUser model).
13
+ * Connected via connectAuthMongo() or connectMongo() (backward compat).
14
+ */
15
+ export const authConnection = mongoose.createConnection();
16
+
17
+ /**
18
+ * Named connection for app/tenant data.
19
+ * Connected via connectAppMongo() or connectMongo() (backward compat).
20
+ * Use this when registering your own models: appConnection.model("Product", schema).
21
+ */
22
+ export const appConnection = mongoose.createConnection();
23
+
24
+ /**
25
+ * Connect the auth connection to its dedicated MongoDB server.
26
+ * Uses MONGO_AUTH_USER_*, MONGO_AUTH_PW_*, MONGO_AUTH_HOST_*, MONGO_AUTH_DB_* env vars.
27
+ */
28
+ export const connectAuthMongo = async (): Promise<void> => {
29
+ const user = isProd ? process.env.MONGO_AUTH_USER_PROD! : process.env.MONGO_AUTH_USER_DEV!;
30
+ const password = isProd ? process.env.MONGO_AUTH_PW_PROD! : process.env.MONGO_AUTH_PW_DEV!;
31
+ const host = isProd ? process.env.MONGO_AUTH_HOST_PROD! : process.env.MONGO_AUTH_HOST_DEV!;
32
+ const db = isProd ? process.env.MONGO_AUTH_DB_PROD! : process.env.MONGO_AUTH_DB_DEV!;
33
+ const uri = buildUri(user, password, host, db);
34
+ await authConnection.openUri(uri);
35
+ log(`[mongo] auth connected to ${host} as ${user}`);
36
+ };
37
+
38
+ /**
39
+ * Connect the app connection to its MongoDB server.
40
+ * Uses MONGO_USER_*, MONGO_PW_*, MONGO_HOST_*, MONGO_DB_* env vars.
41
+ */
42
+ export const connectAppMongo = async (): Promise<void> => {
43
+ const user = isProd ? process.env.MONGO_USER_PROD! : process.env.MONGO_USER_DEV!;
44
+ const password = isProd ? process.env.MONGO_PW_PROD! : process.env.MONGO_PW_DEV!;
45
+ const host = isProd ? process.env.MONGO_HOST_PROD! : process.env.MONGO_HOST_DEV!;
46
+ const db = isProd ? process.env.MONGO_DB_PROD! : process.env.MONGO_DB_DEV!;
47
+ const uri = buildUri(user, password, host, db);
48
+ await appConnection.openUri(uri);
49
+ log(`[mongo] app connected to ${host} as ${user}`);
50
+ };
51
+
52
+ /**
53
+ * Connect both auth and app connections to the same MongoDB server.
54
+ * Backward-compatible shorthand for single-DB setups.
55
+ * Uses MONGO_USER_*, MONGO_PW_*, MONGO_HOST_*, MONGO_DB_* env vars.
56
+ */
57
+ export const connectMongo = async (): Promise<void> => {
58
+ const user = isProd ? process.env.MONGO_USER_PROD! : process.env.MONGO_USER_DEV!;
59
+ const password = isProd ? process.env.MONGO_PW_PROD! : process.env.MONGO_PW_DEV!;
60
+ const host = isProd ? process.env.MONGO_HOST_PROD! : process.env.MONGO_HOST_DEV!;
61
+ const db = isProd ? process.env.MONGO_DB_PROD! : process.env.MONGO_DB_DEV!;
62
+ const uri = buildUri(user, password, host, db);
63
+ await Promise.all([
64
+ authConnection.openUri(uri),
65
+ appConnection.openUri(uri),
66
+ ]);
67
+ log(`[mongo] connected to ${host} as ${user}`);
68
+ };
69
+
70
+ export { mongoose };
@@ -0,0 +1,114 @@
1
+ import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
2
+ import { getRedis } from "./redis";
3
+ import { appConnection } from "./mongo";
4
+ import { getAppName } from "./appConfig";
5
+ import { Schema } from "mongoose";
6
+ import { sqliteStoreOAuthState, sqliteConsumeOAuthState } from "../adapters/sqliteAuth";
7
+ import { memoryStoreOAuthState, memoryConsumeOAuthState } from "../adapters/memoryAuth";
8
+
9
+ export type OAuthProviderConfig = {
10
+ google?: { clientId: string; clientSecret: string; redirectUri: string };
11
+ apple?: { clientId: string; teamId: string; keyId: string; privateKey: string; redirectUri: string };
12
+ };
13
+
14
+ type Providers = { google?: Google; apple?: Apple };
15
+
16
+ let _providers: Providers = {};
17
+
18
+ export const initOAuthProviders = (config: OAuthProviderConfig) => {
19
+ if (config.google) {
20
+ const { clientId, clientSecret, redirectUri } = config.google;
21
+ _providers.google = new Google(clientId, clientSecret, redirectUri);
22
+ }
23
+ if (config.apple) {
24
+ const { clientId, teamId, keyId, privateKey, redirectUri } = config.apple;
25
+ _providers.apple = new Apple(clientId, teamId, keyId, new TextEncoder().encode(privateKey), redirectUri);
26
+ }
27
+ };
28
+
29
+ export const getGoogle = (): Google => {
30
+ if (!_providers.google) throw new Error("Google OAuth not configured");
31
+ return _providers.google;
32
+ };
33
+
34
+ export const getApple = (): Apple => {
35
+ if (!_providers.apple) throw new Error("Apple OAuth not configured");
36
+ return _providers.apple;
37
+ };
38
+
39
+ export const getConfiguredOAuthProviders = (): string[] =>
40
+ (Object.entries(_providers) as [string, unknown][])
41
+ .filter(([, v]) => v != null)
42
+ .map(([k]) => k);
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Mongo OAuth state model
46
+ // ---------------------------------------------------------------------------
47
+
48
+ interface OAuthStateDoc {
49
+ state: string;
50
+ codeVerifier?: string;
51
+ linkUserId?: string;
52
+ expiresAt: Date;
53
+ }
54
+
55
+ const oauthStateSchema = new Schema<OAuthStateDoc>(
56
+ {
57
+ state: { type: String, required: true, unique: true },
58
+ codeVerifier: { type: String },
59
+ linkUserId: { type: String },
60
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
61
+ },
62
+ { collection: "oauth_states" }
63
+ );
64
+
65
+ function getOAuthStateModel() {
66
+ return appConnection.models["OAuthState"] ??
67
+ appConnection.model<OAuthStateDoc>("OAuthState", oauthStateSchema);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Store configuration — set once at startup via setOAuthStateStore()
72
+ // ---------------------------------------------------------------------------
73
+
74
+ type OAuthStateStore = "redis" | "mongo" | "sqlite" | "memory";
75
+ let _oauthStore: OAuthStateStore = "redis";
76
+ export const setOAuthStateStore = (store: OAuthStateStore) => { _oauthStore = store; };
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // State helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const STATE_TTL = 300; // 5 minutes
83
+
84
+ export const storeOAuthState = async (state: string, codeVerifier?: string, linkUserId?: string): Promise<void> => {
85
+ if (_oauthStore === "memory") { memoryStoreOAuthState(state, codeVerifier, linkUserId); return; }
86
+ if (_oauthStore === "sqlite") {
87
+ sqliteStoreOAuthState(state, codeVerifier, linkUserId);
88
+ return;
89
+ }
90
+ if (_oauthStore === "mongo") {
91
+ const expiresAt = new Date(Date.now() + STATE_TTL * 1000);
92
+ await getOAuthStateModel().create({ state, codeVerifier, linkUserId, expiresAt });
93
+ return;
94
+ }
95
+ await getRedis().set(`oauth:${getAppName()}:state:${state}`, JSON.stringify({ codeVerifier, linkUserId }), "EX", STATE_TTL);
96
+ };
97
+
98
+ export const consumeOAuthState = async (state: string): Promise<{ codeVerifier?: string; linkUserId?: string } | null> => {
99
+ if (_oauthStore === "memory") return memoryConsumeOAuthState(state);
100
+ if (_oauthStore === "sqlite") return sqliteConsumeOAuthState(state);
101
+ if (_oauthStore === "mongo") {
102
+ const doc = await getOAuthStateModel()
103
+ .findOneAndDelete({ state, expiresAt: { $gt: new Date() } })
104
+ .lean();
105
+ return doc ? { codeVerifier: doc.codeVerifier, linkUserId: doc.linkUserId } : null;
106
+ }
107
+ const key = `oauth:${getAppName()}:state:${state}`;
108
+ const value = await getRedis().get(key);
109
+ if (!value) return null;
110
+ await getRedis().del(key);
111
+ return JSON.parse(value);
112
+ };
113
+
114
+ export { generateState, generateCodeVerifier };
@@ -0,0 +1,18 @@
1
+ import { Queue, Worker } from "bullmq";
2
+ import type { Processor, QueueOptions, WorkerOptions, Job } from "bullmq";
3
+ import { getRedisConnectionOptions } from "./redis";
4
+
5
+ export const createQueue = <T = unknown, R = unknown>(
6
+ name: string,
7
+ options?: Omit<QueueOptions, "connection">
8
+ ): Queue<T, R> =>
9
+ new Queue<T, R>(name, { connection: getRedisConnectionOptions(), ...options });
10
+
11
+ export const createWorker = <T = unknown, R = unknown>(
12
+ name: string,
13
+ processor: Processor<T, R>,
14
+ options?: Omit<WorkerOptions, "connection">
15
+ ): Worker<T, R> =>
16
+ new Worker<T, R>(name, processor, { connection: getRedisConnectionOptions(), ...options });
17
+
18
+ export type { Job };
@@ -0,0 +1,45 @@
1
+ import Redis from "ioredis";
2
+ import { log } from "./logger";
3
+
4
+ const isProd = process.env.NODE_ENV === "production";
5
+
6
+ export const getRedisConnectionOptions = () => {
7
+ const host_port = isProd ? process.env.REDIS_HOST_PROD : process.env.REDIS_HOST_DEV;
8
+ if (!host_port) throw new Error(`Missing env var: ${isProd ? "REDIS_HOST_PROD" : "REDIS_HOST_DEV"}`);
9
+ const [host, port] = host_port.split(":");
10
+ if (!host || !port) throw new Error(`Invalid Redis host format — expected "host:port", got "${host_port}"`);
11
+
12
+ const username = isProd ? process.env.REDIS_USER_PROD : process.env.REDIS_USER_DEV;
13
+ const password = isProd ? process.env.REDIS_PW_PROD : process.env.REDIS_PW_DEV;
14
+
15
+ return {
16
+ host,
17
+ port: Number(port),
18
+ ...(username && { username }),
19
+ ...(password && { password }),
20
+ };
21
+ };
22
+
23
+ let client: Redis | null = null;
24
+
25
+ const createClient = (): Redis => {
26
+ const redis = new Redis(getRedisConnectionOptions());
27
+
28
+ redis.on("error", (err) => log(`[redis] error: ${err.message}`));
29
+
30
+ return redis;
31
+ };
32
+
33
+ export const connectRedis = (): Promise<void> => {
34
+ if (client) return Promise.resolve();
35
+ client = createClient();
36
+ return new Promise((resolve, reject) => {
37
+ client!.once("ready", () => { log(`[redis] connected to ${getRedisConnectionOptions().host}:${getRedisConnectionOptions().port} as ${getRedisConnectionOptions().username || "default user"}`); resolve(); });
38
+ client!.once("error", reject);
39
+ });
40
+ };
41
+
42
+ export const getRedis = (): Redis => {
43
+ if (!client) throw new Error("Redis not connected — call connectRedis() first");
44
+ return client;
45
+ };
@@ -0,0 +1,23 @@
1
+ import { getAuthAdapter } from "./authAdapter";
2
+
3
+ const requireMethod = (method: string) => {
4
+ throw new Error(`Auth adapter does not implement ${method} — add it to your adapter to manage roles`);
5
+ };
6
+
7
+ export const setUserRoles = async (userId: string, roles: string[]): Promise<void> => {
8
+ const adapter = getAuthAdapter();
9
+ if (!adapter.setRoles) requireMethod("setRoles");
10
+ await adapter.setRoles!(userId, roles);
11
+ };
12
+
13
+ export const addUserRole = async (userId: string, role: string): Promise<void> => {
14
+ const adapter = getAuthAdapter();
15
+ if (!adapter.addRole) requireMethod("addRole");
16
+ await adapter.addRole!(userId, role);
17
+ };
18
+
19
+ export const removeUserRole = async (userId: string, role: string): Promise<void> => {
20
+ const adapter = getAuthAdapter();
21
+ if (!adapter.removeRole) requireMethod("removeRole");
22
+ await adapter.removeRole!(userId, role);
23
+ };
@@ -0,0 +1,91 @@
1
+ import { getRedis } from "./redis";
2
+ import { appConnection } from "./mongo";
3
+ import { getAppName } from "./appConfig";
4
+ import { Schema } from "mongoose";
5
+ import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession } from "../adapters/sqliteAuth";
6
+ import { memoryCreateSession, memoryGetSession, memoryDeleteSession } from "../adapters/memoryAuth";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mongo session model
10
+ // ---------------------------------------------------------------------------
11
+
12
+ interface SessionDoc {
13
+ userId: string;
14
+ token: string;
15
+ expiresAt: Date;
16
+ }
17
+
18
+ const sessionSchema = new Schema<SessionDoc>(
19
+ {
20
+ userId: { type: String, required: true, unique: true },
21
+ token: { type: String, required: true },
22
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
23
+ },
24
+ { collection: "sessions" }
25
+ );
26
+
27
+ function getSessionModel() {
28
+ return appConnection.models["Session"] ??
29
+ appConnection.model<SessionDoc>("Session", sessionSchema);
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Store configuration — set once at startup via setSessionStore()
34
+ // ---------------------------------------------------------------------------
35
+
36
+ type SessionStore = "redis" | "mongo" | "sqlite" | "memory";
37
+ let _store: SessionStore = "redis";
38
+ export const setSessionStore = (store: SessionStore) => { _store = store; };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // TTL
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Public API
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const createSession = async (userId: string, token: string) => {
51
+ if (_store === "memory") { memoryCreateSession(userId, token); return; }
52
+ if (_store === "sqlite") {
53
+ sqliteCreateSession(userId, token);
54
+ return;
55
+ }
56
+ if (_store === "mongo") {
57
+ const expiresAt = new Date(Date.now() + TTL_SECONDS * 1000);
58
+ await getSessionModel().updateOne(
59
+ { userId },
60
+ { $set: { token, expiresAt } },
61
+ { upsert: true }
62
+ );
63
+ return;
64
+ }
65
+ await getRedis().set(`session:${getAppName()}:${userId}`, token, "EX", TTL_SECONDS);
66
+ };
67
+
68
+ export const getSession = async (userId: string) => {
69
+ if (_store === "memory") return memoryGetSession(userId);
70
+ if (_store === "sqlite") return sqliteGetSession(userId);
71
+ if (_store === "mongo") {
72
+ const doc = await getSessionModel()
73
+ .findOne({ userId, expiresAt: { $gt: new Date() } }, "token")
74
+ .lean();
75
+ return doc ? doc.token : null;
76
+ }
77
+ return getRedis().get(`session:${getAppName()}:${userId}`);
78
+ };
79
+
80
+ export const deleteSession = async (userId: string) => {
81
+ if (_store === "memory") { memoryDeleteSession(userId); return; }
82
+ if (_store === "sqlite") {
83
+ sqliteDeleteSession(userId);
84
+ return;
85
+ }
86
+ if (_store === "mongo") {
87
+ await getSessionModel().deleteOne({ userId });
88
+ return;
89
+ }
90
+ await getRedis().del(`session:${getAppName()}:${userId}`);
91
+ };
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { HttpError } from "./HttpError";
3
+
4
+ export const validate = async <T extends z.ZodType>(schema: T, req: Request): Promise<z.output<T>> => {
5
+ try {
6
+ const body = await req.json();
7
+ return schema.parse(body);
8
+ } catch (err) {
9
+ if (err instanceof z.ZodError) {
10
+ throw new HttpError(400, err.issues.map((i) => i.message).join(", "));
11
+ }
12
+ throw err;
13
+ }
14
+ };
package/src/lib/ws.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+
3
+ type WithRooms = { rooms: Set<string> };
4
+ type WithSocketId = { id: string } & WithRooms;
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ let _server: Server<any> | null = null;
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ export const setWsServer = (server: Server<any>) => { _server = server; };
11
+
12
+ export const publish = (topic: string, data: unknown) => {
13
+ _server?.publish(topic, JSON.stringify(data));
14
+ };
15
+
16
+ /** room → Set of socket IDs */
17
+ const _roomRegistry = new Map<string, Set<string>>();
18
+
19
+ /** All rooms that currently have at least one subscriber */
20
+ export const getRooms = (): string[] => [..._roomRegistry.keys()];
21
+
22
+ /** Socket IDs subscribed to a given room */
23
+ export const getRoomSubscribers = (room: string): string[] =>
24
+ [...(_roomRegistry.get(room) ?? [])];
25
+
26
+ type RoomGuard<T extends WithRooms> = (ws: ServerWebSocket<T>, room: string) => boolean | Promise<boolean>;
27
+
28
+ export const handleRoomActions = async <T extends WithSocketId>(
29
+ ws: ServerWebSocket<T>,
30
+ message: string | Buffer,
31
+ onSubscribe?: RoomGuard<T>,
32
+ ): Promise<boolean> => {
33
+ try {
34
+ const data = JSON.parse(typeof message === "string" ? message : Buffer.from(message).toString());
35
+ if (data.action === "subscribe" && typeof data.room === "string") {
36
+ if (onSubscribe && !(await onSubscribe(ws, data.room))) {
37
+ ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
38
+ } else {
39
+ subscribe(ws, data.room);
40
+ ws.send(JSON.stringify({ event: "subscribed", room: data.room }));
41
+ }
42
+ return true;
43
+ }
44
+ if (data.action === "unsubscribe" && typeof data.room === "string") {
45
+ unsubscribe(ws, data.room);
46
+ ws.send(JSON.stringify({ event: "unsubscribed", room: data.room }));
47
+ return true;
48
+ }
49
+ } catch { /* not JSON */ }
50
+ return false;
51
+ };
52
+
53
+ export const subscribe = <T extends WithSocketId>(ws: ServerWebSocket<T>, room: string) => {
54
+ ws.subscribe(room);
55
+ ws.data.rooms.add(room);
56
+ if (!_roomRegistry.has(room)) _roomRegistry.set(room, new Set());
57
+ _roomRegistry.get(room)!.add(ws.data.id);
58
+ };
59
+
60
+ export const unsubscribe = <T extends WithSocketId>(ws: ServerWebSocket<T>, room: string) => {
61
+ ws.unsubscribe(room);
62
+ ws.data.rooms.delete(room);
63
+ const ids = _roomRegistry.get(room);
64
+ if (ids) {
65
+ ids.delete(ws.data.id);
66
+ if (ids.size === 0) _roomRegistry.delete(room);
67
+ }
68
+ };
69
+
70
+ export const getSubscriptions = <T extends WithRooms>(ws: ServerWebSocket<T>): string[] =>
71
+ [...ws.data.rooms];
72
+
73
+ /** Called on socket close to prune the registry. Internal use only. */
74
+ export const cleanupSocket = (socketId: string, rooms: Set<string>) => {
75
+ for (const room of rooms) {
76
+ const ids = _roomRegistry.get(room);
77
+ if (ids) {
78
+ ids.delete(socketId);
79
+ if (ids.size === 0) _roomRegistry.delete(room);
80
+ }
81
+ }
82
+ };
@@ -0,0 +1,15 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+
3
+ const isProd = process.env.NODE_ENV === "production";
4
+ const validToken = isProd ? process.env.BEARER_TOKEN_PROD! : process.env.BEARER_TOKEN_DEV!;
5
+
6
+ export const bearerAuth: MiddlewareHandler = async (c, next) => {
7
+ const header = c.req.header("Authorization");
8
+ const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
9
+
10
+ if (!token || token !== validToken) {
11
+ return c.json({ error: "Unauthorized" }, 401);
12
+ }
13
+
14
+ await next();
15
+ };
@@ -0,0 +1,73 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // CIDR helpers (IPv4 only; IPv6 exact-match supported)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function ipv4ToUint32(ip: string): number {
8
+ const parts = ip.split(".").map(Number);
9
+ return (
10
+ ((parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]!) >>>
11
+ 0
12
+ );
13
+ }
14
+
15
+ function cidrMatchesIpv4(cidr: string, ip: string): boolean {
16
+ const slash = cidr.indexOf("/");
17
+ const network = slash === -1 ? cidr : cidr.slice(0, slash);
18
+ const prefixLen = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10);
19
+ const mask = prefixLen === 0 ? 0 : (~0 << (32 - prefixLen)) >>> 0;
20
+ return (ipv4ToUint32(network) & mask) === (ipv4ToUint32(ip) & mask);
21
+ }
22
+
23
+ const IPV4_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/;
24
+
25
+ function normalizeIp(ip: string): string {
26
+ // Strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4)
27
+ if (ip.startsWith("::ffff:")) return ip.slice(7);
28
+ return ip;
29
+ }
30
+
31
+ function isBlocked(ip: string, blockList: string[]): boolean {
32
+ const normalized = normalizeIp(ip);
33
+ const isV4 = IPV4_RE.test(normalized);
34
+
35
+ for (const entry of blockList) {
36
+ if (isV4) {
37
+ if (cidrMatchesIpv4(entry, normalized)) return true;
38
+ } else {
39
+ // IPv6: exact match only (CIDR support is v2)
40
+ if (entry === normalized) return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Middleware
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface BotProtectionOptions {
51
+ /**
52
+ * List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 exact addresses,
53
+ * or IPv6 exact addresses to block with a 403.
54
+ */
55
+ blockList?: string[];
56
+ }
57
+
58
+ export const botProtection = ({
59
+ blockList = [],
60
+ }: BotProtectionOptions): MiddlewareHandler => {
61
+ if (blockList.length === 0) return (_c, next) => next();
62
+
63
+ return async (c, next) => {
64
+ const raw = c.req.header("x-forwarded-for") ?? "";
65
+ const ip = raw.split(",")[0]?.trim() ?? "unknown";
66
+
67
+ if (ip !== "unknown" && isBlocked(ip, blockList)) {
68
+ return c.json({ error: "Forbidden" }, 403);
69
+ }
70
+
71
+ await next();
72
+ };
73
+ };