@lastshotlabs/bunshot 0.0.6 → 0.0.8
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/dist/adapters/memoryAuth.js +207 -0
- package/dist/adapters/mongoAuth.js +93 -0
- package/dist/adapters/sqliteAuth.js +242 -0
- package/dist/app.js +175 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +37 -27
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.js +17 -0
- package/dist/lib/authAdapter.js +7 -0
- package/dist/lib/authRateLimit.js +77 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.js +8 -0
- package/dist/lib/emailVerification.js +77 -0
- package/dist/lib/fingerprint.js +36 -0
- package/dist/lib/jwt.js +11 -0
- package/dist/lib/logger.js +7 -0
- package/dist/lib/mongo.js +73 -0
- package/dist/lib/oauth.js +82 -0
- package/dist/lib/queue.js +4 -0
- package/dist/lib/redis.js +50 -0
- package/dist/lib/roles.js +22 -0
- package/dist/lib/session.js +68 -0
- package/dist/lib/validate.js +14 -0
- package/dist/lib/ws.js +64 -0
- package/dist/middleware/bearerAuth.js +10 -0
- package/dist/middleware/botProtection.js +50 -0
- package/dist/middleware/cacheResponse.js +158 -0
- package/dist/middleware/cors.js +17 -0
- package/dist/middleware/errorHandler.js +13 -0
- package/dist/middleware/identify.js +33 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/logger.js +7 -0
- package/dist/middleware/rateLimit.js +20 -0
- package/dist/middleware/requireRole.js +36 -0
- package/dist/middleware/requireVerifiedEmail.js +25 -0
- package/dist/middleware/userAuth.js +6 -0
- package/dist/models/AuthUser.js +14 -0
- package/dist/routes/auth.js +207 -0
- package/dist/routes/health.js +22 -0
- package/dist/routes/home.js +16 -0
- package/dist/routes/oauth.js +150 -0
- package/dist/schemas/auth.js +9 -0
- package/dist/server.js +53 -0
- package/dist/services/auth.js +54 -0
- package/dist/ws/index.js +31 -0
- package/package.json +2 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import mongoose from "mongoose";
|
|
2
|
+
import { log } from "./logger";
|
|
3
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
+
function buildUri(user, password, host, db) {
|
|
5
|
+
const [hostPart, queryPart] = host.split("?");
|
|
6
|
+
return `mongodb+srv://${user}:${password}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Named connection used exclusively for auth data (AuthUser model).
|
|
10
|
+
* Connected via connectAuthMongo() or connectMongo() (backward compat).
|
|
11
|
+
*/
|
|
12
|
+
export const authConnection = mongoose.createConnection();
|
|
13
|
+
/**
|
|
14
|
+
* Named connection for app/tenant data.
|
|
15
|
+
* Connected via connectAppMongo() or connectMongo() (backward compat).
|
|
16
|
+
* Use this when registering your own models: appConnection.model("Product", schema).
|
|
17
|
+
*/
|
|
18
|
+
export const appConnection = mongoose.createConnection();
|
|
19
|
+
/**
|
|
20
|
+
* Connect the auth connection to its dedicated MongoDB server.
|
|
21
|
+
* Uses MONGO_AUTH_USER_*, MONGO_AUTH_PW_*, MONGO_AUTH_HOST_*, MONGO_AUTH_DB_* env vars.
|
|
22
|
+
*/
|
|
23
|
+
export const connectAuthMongo = async () => {
|
|
24
|
+
const user = isProd ? process.env.MONGO_AUTH_USER_PROD : process.env.MONGO_AUTH_USER_DEV;
|
|
25
|
+
const password = isProd ? process.env.MONGO_AUTH_PW_PROD : process.env.MONGO_AUTH_PW_DEV;
|
|
26
|
+
const host = isProd ? process.env.MONGO_AUTH_HOST_PROD : process.env.MONGO_AUTH_HOST_DEV;
|
|
27
|
+
const db = isProd ? process.env.MONGO_AUTH_DB_PROD : process.env.MONGO_AUTH_DB_DEV;
|
|
28
|
+
const uri = buildUri(user, password, host, db);
|
|
29
|
+
await authConnection.openUri(uri);
|
|
30
|
+
log(`[mongo] auth connected to ${host} as ${user}`);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Connect the app connection to its MongoDB server.
|
|
34
|
+
* Uses MONGO_USER_*, MONGO_PW_*, MONGO_HOST_*, MONGO_DB_* env vars.
|
|
35
|
+
*/
|
|
36
|
+
export const connectAppMongo = async () => {
|
|
37
|
+
const user = isProd ? process.env.MONGO_USER_PROD : process.env.MONGO_USER_DEV;
|
|
38
|
+
const password = isProd ? process.env.MONGO_PW_PROD : process.env.MONGO_PW_DEV;
|
|
39
|
+
const host = isProd ? process.env.MONGO_HOST_PROD : process.env.MONGO_HOST_DEV;
|
|
40
|
+
const db = isProd ? process.env.MONGO_DB_PROD : process.env.MONGO_DB_DEV;
|
|
41
|
+
const uri = buildUri(user, password, host, db);
|
|
42
|
+
await appConnection.openUri(uri);
|
|
43
|
+
log(`[mongo] app connected to ${host} as ${user}`);
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Connect both auth and app connections to the same MongoDB server.
|
|
47
|
+
* Backward-compatible shorthand for single-DB setups.
|
|
48
|
+
* Uses MONGO_USER_*, MONGO_PW_*, MONGO_HOST_*, MONGO_DB_* env vars.
|
|
49
|
+
*/
|
|
50
|
+
export const connectMongo = async () => {
|
|
51
|
+
const user = isProd ? process.env.MONGO_USER_PROD : process.env.MONGO_USER_DEV;
|
|
52
|
+
const password = isProd ? process.env.MONGO_PW_PROD : process.env.MONGO_PW_DEV;
|
|
53
|
+
const host = isProd ? process.env.MONGO_HOST_PROD : process.env.MONGO_HOST_DEV;
|
|
54
|
+
const db = isProd ? process.env.MONGO_DB_PROD : process.env.MONGO_DB_DEV;
|
|
55
|
+
const uri = buildUri(user, password, host, db);
|
|
56
|
+
await Promise.all([
|
|
57
|
+
authConnection.openUri(uri),
|
|
58
|
+
appConnection.openUri(uri),
|
|
59
|
+
]);
|
|
60
|
+
log(`[mongo] connected to ${host} as ${user}`);
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Close both auth and app Mongo connections.
|
|
64
|
+
* Useful for one-off scripts that need a clean exit.
|
|
65
|
+
*/
|
|
66
|
+
export const disconnectMongo = async () => {
|
|
67
|
+
await Promise.all([
|
|
68
|
+
authConnection.readyState !== 0 ? authConnection.close() : Promise.resolve(),
|
|
69
|
+
appConnection.readyState !== 0 ? appConnection.close() : Promise.resolve(),
|
|
70
|
+
]);
|
|
71
|
+
log("[mongo] disconnected");
|
|
72
|
+
};
|
|
73
|
+
export { mongoose };
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
let _providers = {};
|
|
9
|
+
export const initOAuthProviders = (config) => {
|
|
10
|
+
if (config.google) {
|
|
11
|
+
const { clientId, clientSecret, redirectUri } = config.google;
|
|
12
|
+
_providers.google = new Google(clientId, clientSecret, redirectUri);
|
|
13
|
+
}
|
|
14
|
+
if (config.apple) {
|
|
15
|
+
const { clientId, teamId, keyId, privateKey, redirectUri } = config.apple;
|
|
16
|
+
_providers.apple = new Apple(clientId, teamId, keyId, new TextEncoder().encode(privateKey), redirectUri);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export const getGoogle = () => {
|
|
20
|
+
if (!_providers.google)
|
|
21
|
+
throw new Error("Google OAuth not configured");
|
|
22
|
+
return _providers.google;
|
|
23
|
+
};
|
|
24
|
+
export const getApple = () => {
|
|
25
|
+
if (!_providers.apple)
|
|
26
|
+
throw new Error("Apple OAuth not configured");
|
|
27
|
+
return _providers.apple;
|
|
28
|
+
};
|
|
29
|
+
export const getConfiguredOAuthProviders = () => Object.entries(_providers)
|
|
30
|
+
.filter(([, v]) => v != null)
|
|
31
|
+
.map(([k]) => k);
|
|
32
|
+
const oauthStateSchema = new Schema({
|
|
33
|
+
state: { type: String, required: true, unique: true },
|
|
34
|
+
codeVerifier: { type: String },
|
|
35
|
+
linkUserId: { type: String },
|
|
36
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
37
|
+
}, { collection: "oauth_states" });
|
|
38
|
+
function getOAuthStateModel() {
|
|
39
|
+
return appConnection.models["OAuthState"] ??
|
|
40
|
+
appConnection.model("OAuthState", oauthStateSchema);
|
|
41
|
+
}
|
|
42
|
+
let _oauthStore = "redis";
|
|
43
|
+
export const setOAuthStateStore = (store) => { _oauthStore = store; };
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// State helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const STATE_TTL = 300; // 5 minutes
|
|
48
|
+
export const storeOAuthState = async (state, codeVerifier, linkUserId) => {
|
|
49
|
+
if (_oauthStore === "memory") {
|
|
50
|
+
memoryStoreOAuthState(state, codeVerifier, linkUserId);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (_oauthStore === "sqlite") {
|
|
54
|
+
sqliteStoreOAuthState(state, codeVerifier, linkUserId);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (_oauthStore === "mongo") {
|
|
58
|
+
const expiresAt = new Date(Date.now() + STATE_TTL * 1000);
|
|
59
|
+
await getOAuthStateModel().create({ state, codeVerifier, linkUserId, expiresAt });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await getRedis().set(`oauth:${getAppName()}:state:${state}`, JSON.stringify({ codeVerifier, linkUserId }), "EX", STATE_TTL);
|
|
63
|
+
};
|
|
64
|
+
export const consumeOAuthState = async (state) => {
|
|
65
|
+
if (_oauthStore === "memory")
|
|
66
|
+
return memoryConsumeOAuthState(state);
|
|
67
|
+
if (_oauthStore === "sqlite")
|
|
68
|
+
return sqliteConsumeOAuthState(state);
|
|
69
|
+
if (_oauthStore === "mongo") {
|
|
70
|
+
const doc = await getOAuthStateModel()
|
|
71
|
+
.findOneAndDelete({ state, expiresAt: { $gt: new Date() } })
|
|
72
|
+
.lean();
|
|
73
|
+
return doc ? { codeVerifier: doc.codeVerifier, linkUserId: doc.linkUserId } : null;
|
|
74
|
+
}
|
|
75
|
+
const key = `oauth:${getAppName()}:state:${state}`;
|
|
76
|
+
const value = await getRedis().get(key);
|
|
77
|
+
if (!value)
|
|
78
|
+
return null;
|
|
79
|
+
await getRedis().del(key);
|
|
80
|
+
return JSON.parse(value);
|
|
81
|
+
};
|
|
82
|
+
export { generateState, generateCodeVerifier };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Queue, Worker } from "bullmq";
|
|
2
|
+
import { getRedisConnectionOptions } from "./redis";
|
|
3
|
+
export const createQueue = (name, options) => new Queue(name, { connection: getRedisConnectionOptions(), ...options });
|
|
4
|
+
export const createWorker = (name, processor, options) => new Worker(name, processor, { connection: getRedisConnectionOptions(), ...options });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Redis from "ioredis";
|
|
2
|
+
import { log } from "./logger";
|
|
3
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
+
export const getRedisConnectionOptions = () => {
|
|
5
|
+
const host_port = isProd ? process.env.REDIS_HOST_PROD : process.env.REDIS_HOST_DEV;
|
|
6
|
+
if (!host_port)
|
|
7
|
+
throw new Error(`Missing env var: ${isProd ? "REDIS_HOST_PROD" : "REDIS_HOST_DEV"}`);
|
|
8
|
+
const [host, port] = host_port.split(":");
|
|
9
|
+
if (!host || !port)
|
|
10
|
+
throw new Error(`Invalid Redis host format — expected "host:port", got "${host_port}"`);
|
|
11
|
+
const username = isProd ? process.env.REDIS_USER_PROD : process.env.REDIS_USER_DEV;
|
|
12
|
+
const password = isProd ? process.env.REDIS_PW_PROD : process.env.REDIS_PW_DEV;
|
|
13
|
+
return {
|
|
14
|
+
host,
|
|
15
|
+
port: Number(port),
|
|
16
|
+
...(username && { username }),
|
|
17
|
+
...(password && { password }),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
let client = null;
|
|
21
|
+
const createClient = () => {
|
|
22
|
+
const redis = new Redis(getRedisConnectionOptions());
|
|
23
|
+
redis.on("error", (err) => log(`[redis] error: ${err.message}`));
|
|
24
|
+
return redis;
|
|
25
|
+
};
|
|
26
|
+
export const connectRedis = () => {
|
|
27
|
+
if (client)
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
client = createClient();
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
client.once("ready", () => { log(`[redis] connected to ${getRedisConnectionOptions().host}:${getRedisConnectionOptions().port} as ${getRedisConnectionOptions().username || "default user"}`); resolve(); });
|
|
32
|
+
client.once("error", reject);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Gracefully close the Redis connection.
|
|
37
|
+
* Useful for one-off scripts that need a clean exit.
|
|
38
|
+
*/
|
|
39
|
+
export const disconnectRedis = async () => {
|
|
40
|
+
if (!client)
|
|
41
|
+
return;
|
|
42
|
+
await client.quit();
|
|
43
|
+
client = null;
|
|
44
|
+
log("[redis] disconnected");
|
|
45
|
+
};
|
|
46
|
+
export const getRedis = () => {
|
|
47
|
+
if (!client)
|
|
48
|
+
throw new Error("Redis not connected — call connectRedis() first");
|
|
49
|
+
return client;
|
|
50
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getAuthAdapter } from "./authAdapter";
|
|
2
|
+
const requireMethod = (method) => {
|
|
3
|
+
throw new Error(`Auth adapter does not implement ${method} — add it to your adapter to manage roles`);
|
|
4
|
+
};
|
|
5
|
+
export const setUserRoles = async (userId, roles) => {
|
|
6
|
+
const adapter = getAuthAdapter();
|
|
7
|
+
if (!adapter.setRoles)
|
|
8
|
+
requireMethod("setRoles");
|
|
9
|
+
await adapter.setRoles(userId, roles);
|
|
10
|
+
};
|
|
11
|
+
export const addUserRole = async (userId, role) => {
|
|
12
|
+
const adapter = getAuthAdapter();
|
|
13
|
+
if (!adapter.addRole)
|
|
14
|
+
requireMethod("addRole");
|
|
15
|
+
await adapter.addRole(userId, role);
|
|
16
|
+
};
|
|
17
|
+
export const removeUserRole = async (userId, role) => {
|
|
18
|
+
const adapter = getAuthAdapter();
|
|
19
|
+
if (!adapter.removeRole)
|
|
20
|
+
requireMethod("removeRole");
|
|
21
|
+
await adapter.removeRole(userId, role);
|
|
22
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
const sessionSchema = new Schema({
|
|
8
|
+
userId: { type: String, required: true, unique: true },
|
|
9
|
+
token: { type: String, required: true },
|
|
10
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
11
|
+
}, { collection: "sessions" });
|
|
12
|
+
function getSessionModel() {
|
|
13
|
+
return appConnection.models["Session"] ??
|
|
14
|
+
appConnection.model("Session", sessionSchema);
|
|
15
|
+
}
|
|
16
|
+
let _store = "redis";
|
|
17
|
+
export const setSessionStore = (store) => { _store = store; };
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// TTL
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public API
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const createSession = async (userId, token) => {
|
|
26
|
+
if (_store === "memory") {
|
|
27
|
+
memoryCreateSession(userId, token);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (_store === "sqlite") {
|
|
31
|
+
sqliteCreateSession(userId, token);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (_store === "mongo") {
|
|
35
|
+
const expiresAt = new Date(Date.now() + TTL_SECONDS * 1000);
|
|
36
|
+
await getSessionModel().updateOne({ userId }, { $set: { token, expiresAt } }, { upsert: true });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await getRedis().set(`session:${getAppName()}:${userId}`, token, "EX", TTL_SECONDS);
|
|
40
|
+
};
|
|
41
|
+
export const getSession = async (userId) => {
|
|
42
|
+
if (_store === "memory")
|
|
43
|
+
return memoryGetSession(userId);
|
|
44
|
+
if (_store === "sqlite")
|
|
45
|
+
return sqliteGetSession(userId);
|
|
46
|
+
if (_store === "mongo") {
|
|
47
|
+
const doc = await getSessionModel()
|
|
48
|
+
.findOne({ userId, expiresAt: { $gt: new Date() } }, "token")
|
|
49
|
+
.lean();
|
|
50
|
+
return doc ? doc.token : null;
|
|
51
|
+
}
|
|
52
|
+
return getRedis().get(`session:${getAppName()}:${userId}`);
|
|
53
|
+
};
|
|
54
|
+
export const deleteSession = async (userId) => {
|
|
55
|
+
if (_store === "memory") {
|
|
56
|
+
memoryDeleteSession(userId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (_store === "sqlite") {
|
|
60
|
+
sqliteDeleteSession(userId);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (_store === "mongo") {
|
|
64
|
+
await getSessionModel().deleteOne({ userId });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await getRedis().del(`session:${getAppName()}:${userId}`);
|
|
68
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HttpError } from "./HttpError";
|
|
3
|
+
export const validate = async (schema, req) => {
|
|
4
|
+
try {
|
|
5
|
+
const body = await req.json();
|
|
6
|
+
return schema.parse(body);
|
|
7
|
+
}
|
|
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/dist/lib/ws.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2
|
+
let _server = null;
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
export const setWsServer = (server) => { _server = server; };
|
|
5
|
+
export const publish = (topic, data) => {
|
|
6
|
+
_server?.publish(topic, JSON.stringify(data));
|
|
7
|
+
};
|
|
8
|
+
/** room → Set of socket IDs */
|
|
9
|
+
const _roomRegistry = new Map();
|
|
10
|
+
/** All rooms that currently have at least one subscriber */
|
|
11
|
+
export const getRooms = () => [..._roomRegistry.keys()];
|
|
12
|
+
/** Socket IDs subscribed to a given room */
|
|
13
|
+
export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
|
|
14
|
+
export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(typeof message === "string" ? message : Buffer.from(message).toString());
|
|
17
|
+
if (data.action === "subscribe" && typeof data.room === "string") {
|
|
18
|
+
if (onSubscribe && !(await onSubscribe(ws, data.room))) {
|
|
19
|
+
ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
subscribe(ws, data.room);
|
|
23
|
+
ws.send(JSON.stringify({ event: "subscribed", room: data.room }));
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (data.action === "unsubscribe" && typeof data.room === "string") {
|
|
28
|
+
unsubscribe(ws, data.room);
|
|
29
|
+
ws.send(JSON.stringify({ event: "unsubscribed", room: data.room }));
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* not JSON */ }
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
export const subscribe = (ws, room) => {
|
|
37
|
+
ws.subscribe(room);
|
|
38
|
+
ws.data.rooms.add(room);
|
|
39
|
+
if (!_roomRegistry.has(room))
|
|
40
|
+
_roomRegistry.set(room, new Set());
|
|
41
|
+
_roomRegistry.get(room).add(ws.data.id);
|
|
42
|
+
};
|
|
43
|
+
export const unsubscribe = (ws, room) => {
|
|
44
|
+
ws.unsubscribe(room);
|
|
45
|
+
ws.data.rooms.delete(room);
|
|
46
|
+
const ids = _roomRegistry.get(room);
|
|
47
|
+
if (ids) {
|
|
48
|
+
ids.delete(ws.data.id);
|
|
49
|
+
if (ids.size === 0)
|
|
50
|
+
_roomRegistry.delete(room);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
export const getSubscriptions = (ws) => [...ws.data.rooms];
|
|
54
|
+
/** Called on socket close to prune the registry. Internal use only. */
|
|
55
|
+
export const cleanupSocket = (socketId, rooms) => {
|
|
56
|
+
for (const room of rooms) {
|
|
57
|
+
const ids = _roomRegistry.get(room);
|
|
58
|
+
if (ids) {
|
|
59
|
+
ids.delete(socketId);
|
|
60
|
+
if (ids.size === 0)
|
|
61
|
+
_roomRegistry.delete(room);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
2
|
+
const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
|
|
3
|
+
export const bearerAuth = async (c, next) => {
|
|
4
|
+
const header = c.req.header("Authorization");
|
|
5
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
6
|
+
if (!token || token !== validToken) {
|
|
7
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
8
|
+
}
|
|
9
|
+
await next();
|
|
10
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// CIDR helpers (IPv4 only; IPv6 exact-match supported)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
function ipv4ToUint32(ip) {
|
|
5
|
+
const parts = ip.split(".").map(Number);
|
|
6
|
+
return (((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>>
|
|
7
|
+
0);
|
|
8
|
+
}
|
|
9
|
+
function cidrMatchesIpv4(cidr, ip) {
|
|
10
|
+
const slash = cidr.indexOf("/");
|
|
11
|
+
const network = slash === -1 ? cidr : cidr.slice(0, slash);
|
|
12
|
+
const prefixLen = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10);
|
|
13
|
+
const mask = prefixLen === 0 ? 0 : (~0 << (32 - prefixLen)) >>> 0;
|
|
14
|
+
return (ipv4ToUint32(network) & mask) === (ipv4ToUint32(ip) & mask);
|
|
15
|
+
}
|
|
16
|
+
const IPV4_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/;
|
|
17
|
+
function normalizeIp(ip) {
|
|
18
|
+
// Strip IPv4-mapped IPv6 prefix (::ffff:1.2.3.4)
|
|
19
|
+
if (ip.startsWith("::ffff:"))
|
|
20
|
+
return ip.slice(7);
|
|
21
|
+
return ip;
|
|
22
|
+
}
|
|
23
|
+
function isBlocked(ip, blockList) {
|
|
24
|
+
const normalized = normalizeIp(ip);
|
|
25
|
+
const isV4 = IPV4_RE.test(normalized);
|
|
26
|
+
for (const entry of blockList) {
|
|
27
|
+
if (isV4) {
|
|
28
|
+
if (cidrMatchesIpv4(entry, normalized))
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// IPv6: exact match only (CIDR support is v2)
|
|
33
|
+
if (entry === normalized)
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export const botProtection = ({ blockList = [], }) => {
|
|
40
|
+
if (blockList.length === 0)
|
|
41
|
+
return (_c, next) => next();
|
|
42
|
+
return async (c, next) => {
|
|
43
|
+
const raw = c.req.header("x-forwarded-for") ?? "";
|
|
44
|
+
const ip = raw.split(",")[0]?.trim() ?? "unknown";
|
|
45
|
+
if (ip !== "unknown" && isBlocked(ip, blockList)) {
|
|
46
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
47
|
+
}
|
|
48
|
+
await next();
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { getRedis } from "../lib/redis";
|
|
2
|
+
import { getAppName } from "../lib/appConfig";
|
|
3
|
+
import { appConnection } from "../lib/mongo";
|
|
4
|
+
import { Schema } from "mongoose";
|
|
5
|
+
import { isSqliteReady, sqliteGetCache, sqliteSetCache, sqliteDelCache, sqliteDelCachePattern } from "../adapters/sqliteAuth";
|
|
6
|
+
import { memoryGetCache, memorySetCache, memoryDelCache, memoryDelCachePattern } from "../adapters/memoryAuth";
|
|
7
|
+
const cacheSchema = new Schema({
|
|
8
|
+
key: { type: String, required: true, unique: true },
|
|
9
|
+
value: { type: String, required: true },
|
|
10
|
+
expiresAt: { type: Date, index: { expireAfterSeconds: 0 } },
|
|
11
|
+
}, { collection: "cache_entries" });
|
|
12
|
+
function getCacheModel() {
|
|
13
|
+
return appConnection.models["CacheEntry"] ??
|
|
14
|
+
appConnection.model("CacheEntry", cacheSchema);
|
|
15
|
+
}
|
|
16
|
+
function isMongoReady() {
|
|
17
|
+
return appConnection.readyState === 1;
|
|
18
|
+
}
|
|
19
|
+
function isRedisReady() {
|
|
20
|
+
try {
|
|
21
|
+
getRedis();
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let _defaultCacheStore = "redis";
|
|
29
|
+
export const setCacheStore = (store) => { _defaultCacheStore = store; };
|
|
30
|
+
async function storeGet(store, cacheKey) {
|
|
31
|
+
if (store === "memory")
|
|
32
|
+
return memoryGetCache(cacheKey);
|
|
33
|
+
if (store === "sqlite") {
|
|
34
|
+
if (!isSqliteReady())
|
|
35
|
+
throw new Error(`cacheResponse: store is "sqlite" but SQLite is not initialized. Call setSqliteDb(path) or pass sqliteDb to createServer.`);
|
|
36
|
+
return sqliteGetCache(cacheKey);
|
|
37
|
+
}
|
|
38
|
+
if (store === "mongo") {
|
|
39
|
+
if (!isMongoReady())
|
|
40
|
+
throw new Error(`cacheResponse: store is "mongo" but appConnection is not connected. Ensure connectMongo() or connectAppMongo() is called before handling requests.`);
|
|
41
|
+
const doc = await getCacheModel().findOne({ key: cacheKey }, "value").lean();
|
|
42
|
+
return doc ? doc.value : null;
|
|
43
|
+
}
|
|
44
|
+
return getRedis().get(cacheKey);
|
|
45
|
+
}
|
|
46
|
+
async function storeSet(store, cacheKey, value, ttl) {
|
|
47
|
+
if (store === "memory") {
|
|
48
|
+
memorySetCache(cacheKey, value, ttl);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (store === "sqlite") {
|
|
52
|
+
if (!isSqliteReady())
|
|
53
|
+
throw new Error(`cacheResponse: store is "sqlite" but SQLite is not initialized. Call setSqliteDb(path) or pass sqliteDb to createServer.`);
|
|
54
|
+
sqliteSetCache(cacheKey, value, ttl);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (store === "mongo") {
|
|
58
|
+
if (!isMongoReady())
|
|
59
|
+
throw new Error(`cacheResponse: store is "mongo" but appConnection is not connected. Ensure connectMongo() or connectAppMongo() is called before handling requests.`);
|
|
60
|
+
const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : undefined;
|
|
61
|
+
await getCacheModel().updateOne({ key: cacheKey }, { $set: { value, ...(expiresAt ? { expiresAt } : {}) } }, { upsert: true });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (ttl) {
|
|
65
|
+
await getRedis().setex(cacheKey, ttl, value);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
await getRedis().set(cacheKey, value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function storeDel(store, cacheKey) {
|
|
72
|
+
if (store === "memory") {
|
|
73
|
+
memoryDelCache(cacheKey);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (store === "sqlite") {
|
|
77
|
+
if (!isSqliteReady())
|
|
78
|
+
return;
|
|
79
|
+
sqliteDelCache(cacheKey);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (store === "mongo") {
|
|
83
|
+
if (!isMongoReady())
|
|
84
|
+
return;
|
|
85
|
+
await getCacheModel().deleteOne({ key: cacheKey });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!isRedisReady())
|
|
89
|
+
return;
|
|
90
|
+
await getRedis().del(cacheKey);
|
|
91
|
+
}
|
|
92
|
+
async function storeDelPattern(store, fullPattern) {
|
|
93
|
+
if (store === "memory") {
|
|
94
|
+
memoryDelCachePattern(fullPattern);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (store === "sqlite") {
|
|
98
|
+
if (!isSqliteReady())
|
|
99
|
+
return;
|
|
100
|
+
sqliteDelCachePattern(fullPattern);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (store === "mongo") {
|
|
104
|
+
if (!isMongoReady())
|
|
105
|
+
return;
|
|
106
|
+
const regex = new RegExp("^" + fullPattern.replace(/\*/g, ".*") + "$");
|
|
107
|
+
await getCacheModel().deleteMany({ key: regex });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!isRedisReady())
|
|
111
|
+
return;
|
|
112
|
+
const redis = getRedis();
|
|
113
|
+
let cursor = "0";
|
|
114
|
+
do {
|
|
115
|
+
const [next, keys] = await redis.scan(cursor, "MATCH", fullPattern, "COUNT", 100);
|
|
116
|
+
cursor = next;
|
|
117
|
+
if (keys.length > 0)
|
|
118
|
+
await redis.del(...keys);
|
|
119
|
+
} while (cursor !== "0");
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Public API
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
export const bustCache = async (key) => {
|
|
125
|
+
const cacheKey = `cache:${getAppName()}:${key}`;
|
|
126
|
+
await Promise.all([storeDel("redis", cacheKey), storeDel("mongo", cacheKey), storeDel("sqlite", cacheKey), storeDel("memory", cacheKey)]);
|
|
127
|
+
};
|
|
128
|
+
export const bustCachePattern = async (pattern) => {
|
|
129
|
+
const fullPattern = `cache:${getAppName()}:${pattern}`;
|
|
130
|
+
await Promise.all([storeDelPattern("redis", fullPattern), storeDelPattern("mongo", fullPattern), storeDelPattern("sqlite", fullPattern), storeDelPattern("memory", fullPattern)]);
|
|
131
|
+
};
|
|
132
|
+
export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
|
|
133
|
+
return async (c, next) => {
|
|
134
|
+
const appName = getAppName();
|
|
135
|
+
const rawKey = typeof key === "function" ? key(c) : key;
|
|
136
|
+
const cacheKey = `cache:${appName}:${rawKey}`;
|
|
137
|
+
const cached = await storeGet(store, cacheKey);
|
|
138
|
+
if (cached) {
|
|
139
|
+
const { status, headers, body } = JSON.parse(cached);
|
|
140
|
+
return new Response(body, {
|
|
141
|
+
status,
|
|
142
|
+
headers: { ...headers, "x-cache": "HIT" },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
await next();
|
|
146
|
+
const res = c.res;
|
|
147
|
+
if (res.status >= 200 && res.status < 300) {
|
|
148
|
+
const body = await res.text();
|
|
149
|
+
const headers = {};
|
|
150
|
+
res.headers.forEach((value, name) => { headers[name] = value; });
|
|
151
|
+
await storeSet(store, cacheKey, JSON.stringify({ status: res.status, headers, body }), ttl);
|
|
152
|
+
c.res = new Response(body, {
|
|
153
|
+
status: res.status,
|
|
154
|
+
headers: { ...headers, "x-cache": "MISS" },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const cors = async (req, next) => {
|
|
2
|
+
if (req.method === "OPTIONS") {
|
|
3
|
+
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
4
|
+
}
|
|
5
|
+
const res = await next(req);
|
|
6
|
+
const headers = new Headers(res.headers);
|
|
7
|
+
for (const [k, v] of Object.entries(corsHeaders()))
|
|
8
|
+
headers.set(k, v);
|
|
9
|
+
return new Response(res.body, { status: res.status, headers });
|
|
10
|
+
};
|
|
11
|
+
const corsHeaders = () => {
|
|
12
|
+
return {
|
|
13
|
+
"Access-Control-Allow-Origin": "*",
|
|
14
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
15
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { HttpError } from "../lib/HttpError";
|
|
2
|
+
export const errorHandler = async (req, next) => {
|
|
3
|
+
try {
|
|
4
|
+
return await next(req);
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
console.error(err);
|
|
8
|
+
if (err instanceof HttpError) {
|
|
9
|
+
return Response.json({ error: err.message }, { status: err.status });
|
|
10
|
+
}
|
|
11
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getCookie } from "hono/cookie";
|
|
2
|
+
import { verifyToken } from "../lib/jwt";
|
|
3
|
+
import { getSession } from "../lib/session";
|
|
4
|
+
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
5
|
+
import { log } from "../lib/logger";
|
|
6
|
+
export const identify = async (c, next) => {
|
|
7
|
+
c.set("authUserId", null);
|
|
8
|
+
c.set("roles", null);
|
|
9
|
+
// cookie for browsers, x-user-token header for non-browser clients
|
|
10
|
+
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
11
|
+
log(`[identify] token=${token ? "present" : "absent"}`);
|
|
12
|
+
if (token) {
|
|
13
|
+
try {
|
|
14
|
+
const payload = await verifyToken(token);
|
|
15
|
+
const stored = await getSession(payload.sub);
|
|
16
|
+
log(`[identify] token for authUserId=${payload.sub} verified, checking session...`);
|
|
17
|
+
if (stored === token) {
|
|
18
|
+
c.set("authUserId", payload.sub);
|
|
19
|
+
log(`[identify] authUserId=${payload.sub}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
log("[identify] token/session mismatch — unauthenticated");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
log("[identify] invalid token — unauthenticated");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
log("[identify] no token — unauthenticated");
|
|
31
|
+
}
|
|
32
|
+
await next();
|
|
33
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const applyMiddleware = (handler, ...middleware) => middleware.reduceRight((next, mw) => (req) => mw(req, next), handler);
|