@lastshotlabs/bunshot 0.0.10 → 0.0.16
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 +2510 -1580
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +107 -2
- package/dist/app.js +83 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +15 -5
- package/dist/index.js +10 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +8 -0
- package/dist/models/AuthUser.js +8 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +253 -80
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +21 -2
- package/dist/services/auth.js +97 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +83 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +100 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +19 -10
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createRoute as _createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { getRefId, zodToOpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
3
|
+
const STATUS_SUFFIX = {
|
|
4
|
+
"200": "Response",
|
|
5
|
+
"201": "Response",
|
|
6
|
+
"204": "Response",
|
|
7
|
+
"400": "BadRequestError",
|
|
8
|
+
"401": "UnauthorizedError",
|
|
9
|
+
"403": "ForbiddenError",
|
|
10
|
+
"404": "NotFoundError",
|
|
11
|
+
"409": "ConflictError",
|
|
12
|
+
"422": "ValidationError",
|
|
13
|
+
"429": "RateLimitError",
|
|
14
|
+
"500": "InternalError",
|
|
15
|
+
"501": "NotImplementedError",
|
|
16
|
+
"503": "UnavailableError",
|
|
17
|
+
};
|
|
18
|
+
const METHOD_VERB = {
|
|
19
|
+
get: "Get",
|
|
20
|
+
post: "Create",
|
|
21
|
+
put: "Replace",
|
|
22
|
+
patch: "Update",
|
|
23
|
+
delete: "Delete",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Converts a route method + path into a PascalCase base name for auto-generated schema names.
|
|
27
|
+
* Examples:
|
|
28
|
+
* POST /ledger-items → CreateLedgerItems
|
|
29
|
+
* GET /ledger-items/{id} → GetLedgerItemsById
|
|
30
|
+
* DELETE /auth/sessions/{sessionId} → DeleteAuthSessionsBySessionId
|
|
31
|
+
*/
|
|
32
|
+
function toBaseName(method, path) {
|
|
33
|
+
const m = METHOD_VERB[method.toLowerCase()] ?? (method.charAt(0).toUpperCase() + method.slice(1).toLowerCase());
|
|
34
|
+
const segments = path
|
|
35
|
+
.split("/")
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.map((seg) => {
|
|
38
|
+
if (seg.startsWith("{") && seg.endsWith("}")) {
|
|
39
|
+
const param = seg.slice(1, -1);
|
|
40
|
+
return "By" + param.charAt(0).toUpperCase() + param.slice(1);
|
|
41
|
+
}
|
|
42
|
+
// kebab-case and plain segments → PascalCase
|
|
43
|
+
return seg.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^[a-z]/, (c) => c.toUpperCase());
|
|
44
|
+
});
|
|
45
|
+
return m + segments.join("");
|
|
46
|
+
}
|
|
47
|
+
function maybeRegister(schema, name) {
|
|
48
|
+
if (!schema || typeof schema !== "object" || !("_def" in schema))
|
|
49
|
+
return;
|
|
50
|
+
if (getRefId(schema))
|
|
51
|
+
return; // already named via .openapi()
|
|
52
|
+
// Write directly to the registry instead of calling schema.openapi(name) — the
|
|
53
|
+
// .openapi() method requires extendZodWithOpenApi() to have been called on the
|
|
54
|
+
// same zod instance that created the schema, which isn't guaranteed in tenant apps.
|
|
55
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Registers a Zod schema as a named entry in `components/schemas`.
|
|
59
|
+
*
|
|
60
|
+
* Use this for shared schemas (e.g. shared error types, reusable response shapes)
|
|
61
|
+
* that aren't directly attached to a specific route. Schemas already registered
|
|
62
|
+
* under the same name are silently skipped.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* export const MySchema = registerSchema("MySchema", z.object({ id: z.string() }));
|
|
66
|
+
*/
|
|
67
|
+
export const registerSchema = (name, schema) => {
|
|
68
|
+
if (!getRefId(schema)) {
|
|
69
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
70
|
+
}
|
|
71
|
+
return schema;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Registers multiple Zod schemas at once as named entries in `components/schemas`.
|
|
75
|
+
* Object keys become the schema names. Returns the same object so you can
|
|
76
|
+
* destructure or re-export the schemas normally.
|
|
77
|
+
*
|
|
78
|
+
* Schemas already registered (e.g. via a prior `registerSchema` call) are skipped.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* export const { LedgerItem, Product } = registerSchemas({
|
|
82
|
+
* LedgerItem: z.object({ id: z.string(), amount: z.number() }),
|
|
83
|
+
* Product: z.object({ id: z.string(), price: z.number() }),
|
|
84
|
+
* });
|
|
85
|
+
*/
|
|
86
|
+
export const registerSchemas = (schemas) => {
|
|
87
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
88
|
+
if (!getRefId(schema)) {
|
|
89
|
+
zodToOpenAPIRegistry.add(schema, { _internal: { refId: name } });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return schemas;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Auto-registers a module export as a named OpenAPI schema.
|
|
96
|
+
* Used internally by modelSchemas auto-discovery in createApp.
|
|
97
|
+
* Strips a trailing "Schema" suffix from the export name.
|
|
98
|
+
* Skips non-Zod values and already-registered schemas.
|
|
99
|
+
*/
|
|
100
|
+
export function maybeAutoRegister(exportName, value) {
|
|
101
|
+
if (!value || typeof value !== "object" || !("_def" in value))
|
|
102
|
+
return;
|
|
103
|
+
if (getRefId(value))
|
|
104
|
+
return;
|
|
105
|
+
const name = exportName.endsWith("Schema")
|
|
106
|
+
? exportName.slice(0, -"Schema".length)
|
|
107
|
+
: exportName;
|
|
108
|
+
zodToOpenAPIRegistry.add(value, { _internal: { refId: name } });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Adds an OpenAPI `security` requirement to a route without affecting TypeScript
|
|
112
|
+
* type inference on the handler. Pass each security scheme as a separate object.
|
|
113
|
+
*
|
|
114
|
+
* Use this instead of inlining `security` in `createRoute(...)` — inlining a
|
|
115
|
+
* field typed as `{ [name: string]: string[] }` breaks `c.req.valid()` inference.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* router.openapi(
|
|
119
|
+
* withSecurity(createRoute({ method: "get", path: "/me", ... }), { cookieAuth: [] }, { userToken: [] }),
|
|
120
|
+
* async (c) => { ... }
|
|
121
|
+
* )
|
|
122
|
+
*/
|
|
123
|
+
export const withSecurity = (route, ...schemes) => Object.assign(route, { security: schemes });
|
|
124
|
+
/**
|
|
125
|
+
* Drop-in replacement for `createRoute` from `@hono/zod-openapi`.
|
|
126
|
+
*
|
|
127
|
+
* Automatically registers unnamed request body and response schemas as named
|
|
128
|
+
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
129
|
+
* inlined at every use site. Generated names follow the convention:
|
|
130
|
+
*
|
|
131
|
+
* {Method}{PathSegments}Body — request body
|
|
132
|
+
* {Method}{PathSegments}{StatusCode} — response body
|
|
133
|
+
*
|
|
134
|
+
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
135
|
+
*/
|
|
136
|
+
export const createRoute = (config) => {
|
|
137
|
+
const base = toBaseName(config.method, config.path);
|
|
138
|
+
// Auto-name the JSON request body schema if present and unnamed
|
|
139
|
+
const bodySchema = config.request?.body?.content?.["application/json"]?.schema;
|
|
140
|
+
maybeRegister(bodySchema, `${base}Request`);
|
|
141
|
+
// Auto-name each JSON response schema if present and unnamed
|
|
142
|
+
for (const [status, response] of Object.entries(config.responses ?? {})) {
|
|
143
|
+
const resSchema = response?.content?.["application/json"]?.schema;
|
|
144
|
+
maybeRegister(resSchema, `${base}${STATUS_SUFFIX[status] ?? status}`);
|
|
145
|
+
}
|
|
146
|
+
return _createRoute(config);
|
|
147
|
+
};
|
package/dist/lib/jwt.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const signToken: (userId: string, sessionId: string) => Promise<string>;
|
|
1
|
+
export declare const signToken: (userId: string, sessionId: string, expirySeconds?: number) => Promise<string>;
|
|
2
2
|
export declare const verifyToken: (token: string) => Promise<import("jose").JWTPayload>;
|
package/dist/lib/jwt.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
2
|
const isProd = process.env.NODE_ENV === "production";
|
|
3
3
|
const secret = new TextEncoder().encode(isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV);
|
|
4
|
-
export const signToken = async (userId, sessionId) => new SignJWT({ sub: userId, sid: sessionId })
|
|
4
|
+
export const signToken = async (userId, sessionId, expirySeconds) => new SignJWT({ sub: userId, sid: sessionId })
|
|
5
5
|
.setProtectedHeader({ alg: "HS256" })
|
|
6
|
-
.setExpirationTime("7d")
|
|
6
|
+
.setExpirationTime(expirySeconds ? `${expirySeconds}s` : "7d")
|
|
7
7
|
.sign(secret);
|
|
8
8
|
export const verifyToken = async (token) => {
|
|
9
9
|
const { payload } = await jwtVerify(token, secret);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface MfaChallengeData {
|
|
2
|
+
userId: string;
|
|
3
|
+
emailOtpHash?: string;
|
|
4
|
+
}
|
|
5
|
+
/** Must be called when store is "sqlite" to inject the db instance. */
|
|
6
|
+
export declare const setMfaChallengeSqliteDb: (db: any) => void;
|
|
7
|
+
type MfaChallengeStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
8
|
+
export declare const setMfaChallengeStore: (store: MfaChallengeStore) => void;
|
|
9
|
+
export declare const createMfaChallenge: (userId: string, emailOtpHash?: string) => Promise<string>;
|
|
10
|
+
export declare const consumeMfaChallenge: (token: string) => Promise<MfaChallengeData | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Replace the email OTP hash on an existing challenge without consuming it.
|
|
13
|
+
* Used for the resend flow. Increments resendCount and caps the challenge lifetime.
|
|
14
|
+
* Returns { userId, resendCount } on success, null if challenge not found/expired/max resends reached.
|
|
15
|
+
*/
|
|
16
|
+
export declare const replaceMfaChallengeOtp: (token: string, newEmailOtpHash: string) => Promise<{
|
|
17
|
+
userId: string;
|
|
18
|
+
resendCount: number;
|
|
19
|
+
} | null>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName, getMfaChallengeTtl } from "./appConfig";
|
|
4
|
+
const MAX_RESENDS = 3;
|
|
5
|
+
function getMfaChallengeModel() {
|
|
6
|
+
if (appConnection.models["MfaChallenge"])
|
|
7
|
+
return appConnection.models["MfaChallenge"];
|
|
8
|
+
const { Schema } = mongoose;
|
|
9
|
+
const schema = new Schema({
|
|
10
|
+
token: { type: String, required: true, unique: true },
|
|
11
|
+
userId: { type: String, required: true },
|
|
12
|
+
emailOtpHash: { type: String },
|
|
13
|
+
createdAt: { type: Date, required: true },
|
|
14
|
+
resendCount: { type: Number, required: true, default: 0 },
|
|
15
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
16
|
+
}, { collection: "mfa_challenges" });
|
|
17
|
+
return appConnection.model("MfaChallenge", schema);
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// In-memory store
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const _memoryChallenges = new Map();
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// SQLite store (reuses the existing SQLite DB instance)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
let _sqliteDb = null;
|
|
27
|
+
let _sqliteTableCreated = false;
|
|
28
|
+
/** Must be called when store is "sqlite" to inject the db instance. */
|
|
29
|
+
export const setMfaChallengeSqliteDb = (db) => { _sqliteDb = db; };
|
|
30
|
+
function ensureSqliteMfaTable() {
|
|
31
|
+
if (_sqliteTableCreated || !_sqliteDb)
|
|
32
|
+
return;
|
|
33
|
+
_sqliteDb.run(`CREATE TABLE IF NOT EXISTS mfa_challenges (
|
|
34
|
+
token TEXT PRIMARY KEY,
|
|
35
|
+
userId TEXT NOT NULL,
|
|
36
|
+
emailOtpHash TEXT,
|
|
37
|
+
createdAt INTEGER NOT NULL,
|
|
38
|
+
resendCount INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
expiresAt INTEGER NOT NULL
|
|
40
|
+
)`);
|
|
41
|
+
// Migrate pre-existing tables that lack the new columns
|
|
42
|
+
try {
|
|
43
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN emailOtpHash TEXT");
|
|
44
|
+
}
|
|
45
|
+
catch { /* already exists */ }
|
|
46
|
+
try {
|
|
47
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0");
|
|
48
|
+
}
|
|
49
|
+
catch { /* already exists */ }
|
|
50
|
+
try {
|
|
51
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN resendCount INTEGER NOT NULL DEFAULT 0");
|
|
52
|
+
}
|
|
53
|
+
catch { /* already exists */ }
|
|
54
|
+
_sqliteTableCreated = true;
|
|
55
|
+
}
|
|
56
|
+
let _store = "redis";
|
|
57
|
+
export const setMfaChallengeStore = (store) => { _store = store; };
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Public API
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export const createMfaChallenge = async (userId, emailOtpHash) => {
|
|
62
|
+
const token = crypto.randomUUID();
|
|
63
|
+
const ttl = getMfaChallengeTtl();
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (_store === "memory") {
|
|
66
|
+
_memoryChallenges.set(token, { userId, emailOtpHash, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
if (_store === "sqlite") {
|
|
70
|
+
ensureSqliteMfaTable();
|
|
71
|
+
_sqliteDb.run("INSERT INTO mfa_challenges (token, userId, emailOtpHash, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, 0, ?)", [token, userId, emailOtpHash ?? null, now, now + ttl * 1000]);
|
|
72
|
+
return token;
|
|
73
|
+
}
|
|
74
|
+
if (_store === "mongo") {
|
|
75
|
+
await getMfaChallengeModel().create({
|
|
76
|
+
token,
|
|
77
|
+
userId,
|
|
78
|
+
emailOtpHash,
|
|
79
|
+
createdAt: new Date(now),
|
|
80
|
+
resendCount: 0,
|
|
81
|
+
expiresAt: new Date(now + ttl * 1000),
|
|
82
|
+
});
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
// redis
|
|
86
|
+
await getRedis().set(`mfachallenge:${getAppName()}:${token}`, JSON.stringify({ userId, emailOtpHash, createdAt: now, resendCount: 0 }), "EX", ttl);
|
|
87
|
+
return token;
|
|
88
|
+
};
|
|
89
|
+
export const consumeMfaChallenge = async (token) => {
|
|
90
|
+
if (_store === "memory") {
|
|
91
|
+
const entry = _memoryChallenges.get(token);
|
|
92
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
93
|
+
_memoryChallenges.delete(token);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
_memoryChallenges.delete(token);
|
|
97
|
+
return { userId: entry.userId, emailOtpHash: entry.emailOtpHash };
|
|
98
|
+
}
|
|
99
|
+
if (_store === "sqlite") {
|
|
100
|
+
ensureSqliteMfaTable();
|
|
101
|
+
const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, emailOtpHash").get(token, Date.now());
|
|
102
|
+
return row ? { userId: row.userId, emailOtpHash: row.emailOtpHash ?? undefined } : null;
|
|
103
|
+
}
|
|
104
|
+
if (_store === "mongo") {
|
|
105
|
+
const doc = await getMfaChallengeModel().findOneAndDelete({ token, expiresAt: { $gt: new Date() } });
|
|
106
|
+
return doc ? { userId: doc.userId, emailOtpHash: doc.emailOtpHash } : null;
|
|
107
|
+
}
|
|
108
|
+
// redis
|
|
109
|
+
const key = `mfachallenge:${getAppName()}:${token}`;
|
|
110
|
+
const raw = await getRedis().get(key);
|
|
111
|
+
if (!raw)
|
|
112
|
+
return null;
|
|
113
|
+
await getRedis().del(key);
|
|
114
|
+
const data = JSON.parse(raw);
|
|
115
|
+
return { userId: data.userId, emailOtpHash: data.emailOtpHash };
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Replace the email OTP hash on an existing challenge without consuming it.
|
|
119
|
+
* Used for the resend flow. Increments resendCount and caps the challenge lifetime.
|
|
120
|
+
* Returns { userId, resendCount } on success, null if challenge not found/expired/max resends reached.
|
|
121
|
+
*/
|
|
122
|
+
export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
|
|
123
|
+
const ttl = getMfaChallengeTtl();
|
|
124
|
+
if (_store === "memory") {
|
|
125
|
+
const entry = _memoryChallenges.get(token);
|
|
126
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
127
|
+
_memoryChallenges.delete(token);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (entry.resendCount >= MAX_RESENDS)
|
|
131
|
+
return null;
|
|
132
|
+
entry.emailOtpHash = newEmailOtpHash;
|
|
133
|
+
entry.resendCount++;
|
|
134
|
+
// Cap lifetime: min(now + ttl, createdAt + ttl * 3)
|
|
135
|
+
const maxExpiry = entry.createdAt + ttl * 3 * 1000;
|
|
136
|
+
entry.expiresAt = Math.min(Date.now() + ttl * 1000, maxExpiry);
|
|
137
|
+
return { userId: entry.userId, resendCount: entry.resendCount };
|
|
138
|
+
}
|
|
139
|
+
if (_store === "sqlite") {
|
|
140
|
+
ensureSqliteMfaTable();
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const existing = _sqliteDb.query("SELECT createdAt, resendCount FROM mfa_challenges WHERE token = ? AND expiresAt > ?").get(token, now);
|
|
143
|
+
if (!existing || existing.resendCount >= MAX_RESENDS)
|
|
144
|
+
return null;
|
|
145
|
+
const newExpiry = Math.min(now + ttl * 1000, existing.createdAt + ttl * 3 * 1000);
|
|
146
|
+
const newCount = existing.resendCount + 1;
|
|
147
|
+
const row = _sqliteDb.query("UPDATE mfa_challenges SET emailOtpHash = ?, resendCount = ?, expiresAt = ? WHERE token = ? RETURNING userId").get(newEmailOtpHash, newCount, newExpiry, token);
|
|
148
|
+
return row ? { userId: row.userId, resendCount: newCount } : null;
|
|
149
|
+
}
|
|
150
|
+
if (_store === "mongo") {
|
|
151
|
+
const now = new Date();
|
|
152
|
+
const doc = await getMfaChallengeModel().findOneAndUpdate({ token, expiresAt: { $gt: now }, resendCount: { $lt: MAX_RESENDS } }, [
|
|
153
|
+
{
|
|
154
|
+
$set: {
|
|
155
|
+
emailOtpHash: newEmailOtpHash,
|
|
156
|
+
resendCount: { $add: ["$resendCount", 1] },
|
|
157
|
+
expiresAt: {
|
|
158
|
+
$min: [
|
|
159
|
+
new Date(Date.now() + ttl * 1000),
|
|
160
|
+
{ $add: ["$createdAt", ttl * 3 * 1000] },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
], { new: true });
|
|
166
|
+
return doc ? { userId: doc.userId, resendCount: doc.resendCount } : null;
|
|
167
|
+
}
|
|
168
|
+
// redis
|
|
169
|
+
const key = `mfachallenge:${getAppName()}:${token}`;
|
|
170
|
+
const raw = await getRedis().get(key);
|
|
171
|
+
if (!raw)
|
|
172
|
+
return null;
|
|
173
|
+
const data = JSON.parse(raw);
|
|
174
|
+
if (data.resendCount >= MAX_RESENDS)
|
|
175
|
+
return null;
|
|
176
|
+
data.emailOtpHash = newEmailOtpHash;
|
|
177
|
+
data.resendCount++;
|
|
178
|
+
// Cap lifetime
|
|
179
|
+
const maxExpiry = data.createdAt + ttl * 3 * 1000;
|
|
180
|
+
const newExpiry = Math.min(Date.now() + ttl * 1000, maxExpiry);
|
|
181
|
+
const remainingTtl = Math.max(1, Math.ceil((newExpiry - Date.now()) / 1000));
|
|
182
|
+
await getRedis().set(key, JSON.stringify(data), "EX", remainingTtl);
|
|
183
|
+
return { userId: data.userId, resendCount: data.resendCount };
|
|
184
|
+
};
|
package/dist/lib/queue.d.ts
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
import type { Queue as QueueType, Worker as WorkerType, Processor, QueueOptions, WorkerOptions, Job } from "bullmq";
|
|
2
2
|
export declare const createQueue: <T = unknown, R = unknown>(name: string, options?: Omit<QueueOptions, "connection">) => QueueType<T, R>;
|
|
3
3
|
export declare const createWorker: <T = unknown, R = unknown>(name: string, processor: Processor<T, R>, options?: Omit<WorkerOptions, "connection">) => WorkerType<T, R>;
|
|
4
|
+
export declare const getRegisteredCronNames: () => ReadonlySet<string>;
|
|
5
|
+
export interface CronSchedule {
|
|
6
|
+
/** Cron expression. Mutually exclusive with `every`. */
|
|
7
|
+
cron?: string;
|
|
8
|
+
/** Interval in milliseconds. Mutually exclusive with `cron`. */
|
|
9
|
+
every?: number;
|
|
10
|
+
/** Timezone for cron expressions. */
|
|
11
|
+
timezone?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const createCronWorker: <T = void, R = unknown>(name: string, processor: Processor<T, R>, schedule: CronSchedule, options?: Omit<WorkerOptions, "connection">) => {
|
|
14
|
+
worker: WorkerType<T, R>;
|
|
15
|
+
queue: QueueType<T, R>;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Remove job schedulers that are no longer registered.
|
|
19
|
+
* Called automatically after worker discovery in createServer.
|
|
20
|
+
* Can also be called manually for workers managed outside workersDir.
|
|
21
|
+
*/
|
|
22
|
+
export declare const cleanupStaleSchedulers: (activeNames: string[]) => Promise<void>;
|
|
23
|
+
export interface DLQOptions<T = unknown> {
|
|
24
|
+
/** Max jobs to keep in the DLQ. Default: 1000. */
|
|
25
|
+
maxSize?: number;
|
|
26
|
+
/** Called when a job is moved to the DLQ. */
|
|
27
|
+
onDeadLetter?: (job: Job<T>, error: Error) => Promise<void>;
|
|
28
|
+
/** Auto-retry delay in ms. No auto-retry by default. */
|
|
29
|
+
retryAfter?: number;
|
|
30
|
+
/** Preserve original job options on retry. Default: true. */
|
|
31
|
+
preserveJobOptions?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare const createDLQHandler: <T = unknown>(sourceWorker: WorkerType<T>, sourceQueueName: string, options?: DLQOptions<T>) => {
|
|
34
|
+
dlqQueue: QueueType<T>;
|
|
35
|
+
retryJob: (jobId: string) => Promise<void>;
|
|
36
|
+
};
|
|
4
37
|
export type { Job };
|
package/dist/lib/queue.js
CHANGED
|
@@ -17,3 +17,101 @@ export const createWorker = (name, processor, options) => {
|
|
|
17
17
|
const { Worker } = requireBullMQ();
|
|
18
18
|
return new Worker(name, processor, { connection: getRedisConnectionOptions(), ...options });
|
|
19
19
|
};
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Cron worker
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Tracks all registered cron scheduler names for ghost job cleanup. */
|
|
24
|
+
const _registeredCronNames = new Set();
|
|
25
|
+
export const getRegisteredCronNames = () => _registeredCronNames;
|
|
26
|
+
export const createCronWorker = (name, processor, schedule, options) => {
|
|
27
|
+
const { Queue, Worker } = requireBullMQ();
|
|
28
|
+
const connection = getRedisConnectionOptions();
|
|
29
|
+
const queue = new Queue(name, { connection });
|
|
30
|
+
const worker = new Worker(name, processor, { connection, ...options });
|
|
31
|
+
_registeredCronNames.add(name);
|
|
32
|
+
// Use upsertJobScheduler — idempotent across restarts
|
|
33
|
+
if (schedule.cron) {
|
|
34
|
+
queue.upsertJobScheduler(name, { pattern: schedule.cron, tz: schedule.timezone }, { name });
|
|
35
|
+
}
|
|
36
|
+
else if (schedule.every) {
|
|
37
|
+
queue.upsertJobScheduler(name, { every: schedule.every }, { name });
|
|
38
|
+
}
|
|
39
|
+
return { worker, queue };
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Remove job schedulers that are no longer registered.
|
|
43
|
+
* Called automatically after worker discovery in createServer.
|
|
44
|
+
* Can also be called manually for workers managed outside workersDir.
|
|
45
|
+
*/
|
|
46
|
+
export const cleanupStaleSchedulers = async (activeNames) => {
|
|
47
|
+
const { Queue } = requireBullMQ();
|
|
48
|
+
const connection = getRedisConnectionOptions();
|
|
49
|
+
const activeSet = new Set(activeNames);
|
|
50
|
+
// Check all known queue names for stale schedulers
|
|
51
|
+
for (const name of _registeredCronNames) {
|
|
52
|
+
if (activeSet.has(name))
|
|
53
|
+
continue;
|
|
54
|
+
const queue = new Queue(name, { connection });
|
|
55
|
+
try {
|
|
56
|
+
await queue.removeJobScheduler(name);
|
|
57
|
+
}
|
|
58
|
+
catch { /* scheduler may not exist */ }
|
|
59
|
+
await queue.close();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
export const createDLQHandler = (sourceWorker, sourceQueueName, options) => {
|
|
63
|
+
const { Queue } = requireBullMQ();
|
|
64
|
+
const connection = getRedisConnectionOptions();
|
|
65
|
+
const dlqName = `${sourceQueueName}-dlq`;
|
|
66
|
+
const dlqQueue = new Queue(dlqName, { connection });
|
|
67
|
+
const maxSize = options?.maxSize ?? 1000;
|
|
68
|
+
const preserveJobOptions = options?.preserveJobOptions ?? true;
|
|
69
|
+
sourceWorker.on("failed", async (job, error) => {
|
|
70
|
+
if (!job)
|
|
71
|
+
return;
|
|
72
|
+
// Only move to DLQ when all attempts are exhausted
|
|
73
|
+
if (job.attemptsMade < (job.opts?.attempts ?? 1))
|
|
74
|
+
return;
|
|
75
|
+
await dlqQueue.add(`dlq:${job.name}`, job.data, {
|
|
76
|
+
...(preserveJobOptions ? {
|
|
77
|
+
delay: job.opts?.delay,
|
|
78
|
+
priority: job.opts?.priority,
|
|
79
|
+
attempts: job.opts?.attempts,
|
|
80
|
+
backoff: job.opts?.backoff,
|
|
81
|
+
} : {}),
|
|
82
|
+
jobId: `dlq:${job.id}`,
|
|
83
|
+
});
|
|
84
|
+
if (options?.onDeadLetter) {
|
|
85
|
+
try {
|
|
86
|
+
await options.onDeadLetter(job, error);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
console.error(`[dlq:${sourceQueueName}] onDeadLetter callback error:`, e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Trim DLQ to maxSize
|
|
93
|
+
const waitingCount = await dlqQueue.getWaitingCount();
|
|
94
|
+
if (waitingCount > maxSize) {
|
|
95
|
+
const excess = waitingCount - maxSize;
|
|
96
|
+
const jobs = await dlqQueue.getWaiting(0, excess - 1);
|
|
97
|
+
for (const j of jobs) {
|
|
98
|
+
await j.remove();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const sourceQueue = new Queue(sourceQueueName, { connection });
|
|
103
|
+
const retryJob = async (jobId) => {
|
|
104
|
+
const job = await dlqQueue.getJob(jobId);
|
|
105
|
+
if (!job)
|
|
106
|
+
throw new Error(`Job ${jobId} not found in DLQ`);
|
|
107
|
+
const opts = preserveJobOptions ? {
|
|
108
|
+
delay: job.opts?.delay,
|
|
109
|
+
priority: job.opts?.priority,
|
|
110
|
+
attempts: job.opts?.attempts,
|
|
111
|
+
backoff: job.opts?.backoff,
|
|
112
|
+
} : {};
|
|
113
|
+
await sourceQueue.add(job.name, job.data, opts);
|
|
114
|
+
await job.remove();
|
|
115
|
+
};
|
|
116
|
+
return { dlqQueue, retryJob };
|
|
117
|
+
};
|
package/dist/lib/roles.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare const setUserRoles: (userId: string, roles: string[]) => Promise<void>;
|
|
2
2
|
export declare const addUserRole: (userId: string, role: string) => Promise<void>;
|
|
3
3
|
export declare const removeUserRole: (userId: string, role: string) => Promise<void>;
|
|
4
|
+
export declare const getTenantRoles: (userId: string, tenantId: string) => Promise<string[]>;
|
|
5
|
+
export declare const setTenantRoles: (userId: string, tenantId: string, roles: string[]) => Promise<void>;
|
|
6
|
+
export declare const addTenantRole: (userId: string, tenantId: string, role: string) => Promise<void>;
|
|
7
|
+
export declare const removeTenantRole: (userId: string, tenantId: string, role: string) => Promise<void>;
|
package/dist/lib/roles.js
CHANGED
|
@@ -20,3 +20,30 @@ export const removeUserRole = async (userId, role) => {
|
|
|
20
20
|
requireMethod("removeRole");
|
|
21
21
|
await adapter.removeRole(userId, role);
|
|
22
22
|
};
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Tenant-scoped role helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export const getTenantRoles = async (userId, tenantId) => {
|
|
27
|
+
const adapter = getAuthAdapter();
|
|
28
|
+
if (!adapter.getTenantRoles)
|
|
29
|
+
requireMethod("getTenantRoles");
|
|
30
|
+
return adapter.getTenantRoles(userId, tenantId);
|
|
31
|
+
};
|
|
32
|
+
export const setTenantRoles = async (userId, tenantId, roles) => {
|
|
33
|
+
const adapter = getAuthAdapter();
|
|
34
|
+
if (!adapter.setTenantRoles)
|
|
35
|
+
requireMethod("setTenantRoles");
|
|
36
|
+
await adapter.setTenantRoles(userId, tenantId, roles);
|
|
37
|
+
};
|
|
38
|
+
export const addTenantRole = async (userId, tenantId, role) => {
|
|
39
|
+
const adapter = getAuthAdapter();
|
|
40
|
+
if (!adapter.addTenantRole)
|
|
41
|
+
requireMethod("addTenantRole");
|
|
42
|
+
await adapter.addTenantRole(userId, tenantId, role);
|
|
43
|
+
};
|
|
44
|
+
export const removeTenantRole = async (userId, tenantId, role) => {
|
|
45
|
+
const adapter = getAuthAdapter();
|
|
46
|
+
if (!adapter.removeTenantRole)
|
|
47
|
+
requireMethod("removeTenantRole");
|
|
48
|
+
await adapter.removeTenantRole(userId, tenantId, role);
|
|
49
|
+
};
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface SessionInfo {
|
|
|
11
11
|
userAgent?: string;
|
|
12
12
|
isActive: boolean;
|
|
13
13
|
}
|
|
14
|
+
export interface RefreshResult {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
newRefreshToken: string;
|
|
18
|
+
}
|
|
14
19
|
type SessionStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
15
20
|
export declare const setSessionStore: (store: SessionStore) => void;
|
|
16
21
|
export declare const createSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => Promise<void>;
|
|
@@ -19,5 +24,12 @@ export declare const deleteSession: (sessionId: string) => Promise<void>;
|
|
|
19
24
|
export declare const getUserSessions: (userId: string) => Promise<SessionInfo[]>;
|
|
20
25
|
export declare const getActiveSessionCount: (userId: string) => Promise<number>;
|
|
21
26
|
export declare const evictOldestSession: (userId: string) => Promise<void>;
|
|
27
|
+
export declare const deleteUserSessions: (userId: string) => Promise<void>;
|
|
22
28
|
export declare const updateSessionLastActive: (sessionId: string) => Promise<void>;
|
|
29
|
+
/** Store a refresh token on an existing session (called after session creation). */
|
|
30
|
+
export declare const setRefreshToken: (sessionId: string, refreshToken: string) => Promise<void>;
|
|
31
|
+
/** Look up a session by refresh token. Handles grace window and theft detection. */
|
|
32
|
+
export declare const getSessionByRefreshToken: (refreshToken: string) => Promise<RefreshResult | null>;
|
|
33
|
+
/** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
|
|
34
|
+
export declare const rotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => Promise<void>;
|
|
23
35
|
export {};
|