@lastshotlabs/bunshot 0.0.13 → 0.0.18
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 +2816 -1747
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +177 -2
- package/dist/adapters/mongoAuth.js +94 -0
- package/dist/adapters/sqliteAuth.d.ts +9 -0
- package/dist/adapters/sqliteAuth.js +190 -2
- package/dist/app.d.ts +120 -2
- package/dist/app.js +104 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +24 -8
- package/dist/index.js +15 -5
- package/dist/lib/appConfig.d.ts +81 -0
- package/dist/lib/appConfig.js +30 -0
- package/dist/lib/authAdapter.d.ts +54 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -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/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +19 -6
- package/dist/lib/mfaChallenge.d.ts +42 -0
- package/dist/lib/mfaChallenge.js +293 -0
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/resetPassword.js +12 -16
- 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 +165 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +18 -3
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +7 -5
- 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 +17 -0
- package/dist/models/AuthUser.js +17 -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 +173 -30
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +5 -0
- package/dist/routes/mfa.js +616 -0
- package/dist/routes/oauth.js +378 -23
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +19 -3
- package/dist/services/auth.d.ts +18 -5
- package/dist/services/auth.js +112 -18
- package/dist/services/mfa.d.ts +84 -0
- package/dist/services/mfa.js +543 -0
- package/dist/ws/index.js +3 -2
- 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 +634 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +155 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +117 -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 +92 -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 +66 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +189 -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 +47 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +117 -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 +101 -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 +30 -9
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName, getMfaChallengeTtl } from "./appConfig";
|
|
4
|
+
import { sha256 } from "./crypto";
|
|
5
|
+
const MAX_RESENDS = 3;
|
|
6
|
+
function getMfaChallengeModel() {
|
|
7
|
+
if (appConnection.models["MfaChallenge"])
|
|
8
|
+
return appConnection.models["MfaChallenge"];
|
|
9
|
+
const { Schema } = mongoose;
|
|
10
|
+
const schema = new Schema({
|
|
11
|
+
token: { type: String, required: true, unique: true },
|
|
12
|
+
userId: { type: String, required: true },
|
|
13
|
+
purpose: { type: String, required: true, default: "login" },
|
|
14
|
+
emailOtpHash: { type: String },
|
|
15
|
+
webauthnChallenge: { type: String },
|
|
16
|
+
createdAt: { type: Date, required: true },
|
|
17
|
+
resendCount: { type: Number, required: true, default: 0 },
|
|
18
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
19
|
+
}, { collection: "mfa_challenges" });
|
|
20
|
+
return appConnection.model("MfaChallenge", schema);
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// In-memory store
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const _memoryChallenges = new Map();
|
|
26
|
+
/** Reset all in-memory MFA challenge state. Called by clearMemoryStore(). */
|
|
27
|
+
export const clearMemoryMfaChallenges = () => { _memoryChallenges.clear(); };
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// SQLite store (reuses the existing SQLite DB instance)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
let _sqliteDb = null;
|
|
32
|
+
let _sqliteTableCreated = false;
|
|
33
|
+
/** Must be called when store is "sqlite" to inject the db instance. */
|
|
34
|
+
export const setMfaChallengeSqliteDb = (db) => { _sqliteDb = db; };
|
|
35
|
+
function ensureSqliteMfaTable() {
|
|
36
|
+
if (_sqliteTableCreated || !_sqliteDb)
|
|
37
|
+
return;
|
|
38
|
+
_sqliteDb.run(`CREATE TABLE IF NOT EXISTS mfa_challenges (
|
|
39
|
+
token TEXT PRIMARY KEY,
|
|
40
|
+
userId TEXT NOT NULL,
|
|
41
|
+
purpose TEXT NOT NULL DEFAULT 'login',
|
|
42
|
+
emailOtpHash TEXT,
|
|
43
|
+
webauthnChallenge TEXT,
|
|
44
|
+
createdAt INTEGER NOT NULL,
|
|
45
|
+
resendCount INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
expiresAt INTEGER NOT NULL
|
|
47
|
+
)`);
|
|
48
|
+
// Migrate pre-existing tables that lack newer columns
|
|
49
|
+
try {
|
|
50
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN emailOtpHash TEXT");
|
|
51
|
+
}
|
|
52
|
+
catch { /* already exists */ }
|
|
53
|
+
try {
|
|
54
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0");
|
|
55
|
+
}
|
|
56
|
+
catch { /* already exists */ }
|
|
57
|
+
try {
|
|
58
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN resendCount INTEGER NOT NULL DEFAULT 0");
|
|
59
|
+
}
|
|
60
|
+
catch { /* already exists */ }
|
|
61
|
+
try {
|
|
62
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'login'");
|
|
63
|
+
}
|
|
64
|
+
catch { /* already exists */ }
|
|
65
|
+
try {
|
|
66
|
+
_sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN webauthnChallenge TEXT");
|
|
67
|
+
}
|
|
68
|
+
catch { /* already exists */ }
|
|
69
|
+
_sqliteTableCreated = true;
|
|
70
|
+
}
|
|
71
|
+
let _store = "redis";
|
|
72
|
+
export const setMfaChallengeStore = (store) => { _store = store; };
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Public API
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
export const createMfaChallenge = async (userId, options) => {
|
|
77
|
+
const token = crypto.randomUUID();
|
|
78
|
+
const hash = sha256(token);
|
|
79
|
+
const ttl = getMfaChallengeTtl();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const purpose = "login";
|
|
82
|
+
const emailOtpHash = options?.emailOtpHash;
|
|
83
|
+
const webauthnChallenge = options?.webauthnChallenge;
|
|
84
|
+
if (_store === "memory") {
|
|
85
|
+
_memoryChallenges.set(hash, { userId, purpose, emailOtpHash, webauthnChallenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
|
|
86
|
+
return token;
|
|
87
|
+
}
|
|
88
|
+
if (_store === "sqlite") {
|
|
89
|
+
ensureSqliteMfaTable();
|
|
90
|
+
_sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, emailOtpHash, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, emailOtpHash ?? null, webauthnChallenge ?? null, now, now + ttl * 1000]);
|
|
91
|
+
return token;
|
|
92
|
+
}
|
|
93
|
+
if (_store === "mongo") {
|
|
94
|
+
await getMfaChallengeModel().create({
|
|
95
|
+
token: hash,
|
|
96
|
+
userId,
|
|
97
|
+
purpose,
|
|
98
|
+
emailOtpHash,
|
|
99
|
+
webauthnChallenge,
|
|
100
|
+
createdAt: new Date(now),
|
|
101
|
+
resendCount: 0,
|
|
102
|
+
expiresAt: new Date(now + ttl * 1000),
|
|
103
|
+
});
|
|
104
|
+
return token;
|
|
105
|
+
}
|
|
106
|
+
// redis
|
|
107
|
+
await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, emailOtpHash, webauthnChallenge, createdAt: now, resendCount: 0 }), "EX", ttl);
|
|
108
|
+
return token;
|
|
109
|
+
};
|
|
110
|
+
export const consumeMfaChallenge = async (token) => {
|
|
111
|
+
const hash = sha256(token);
|
|
112
|
+
if (_store === "memory") {
|
|
113
|
+
const entry = _memoryChallenges.get(hash);
|
|
114
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
115
|
+
_memoryChallenges.delete(hash);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
_memoryChallenges.delete(hash);
|
|
119
|
+
if (entry.purpose !== "login")
|
|
120
|
+
return null;
|
|
121
|
+
return { userId: entry.userId, purpose: entry.purpose, emailOtpHash: entry.emailOtpHash, webauthnChallenge: entry.webauthnChallenge };
|
|
122
|
+
}
|
|
123
|
+
if (_store === "sqlite") {
|
|
124
|
+
ensureSqliteMfaTable();
|
|
125
|
+
const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, purpose, emailOtpHash, webauthnChallenge").get(hash, Date.now());
|
|
126
|
+
if (!row || row.purpose !== "login")
|
|
127
|
+
return null;
|
|
128
|
+
return { userId: row.userId, purpose: "login", emailOtpHash: row.emailOtpHash ?? undefined, webauthnChallenge: row.webauthnChallenge ?? undefined };
|
|
129
|
+
}
|
|
130
|
+
if (_store === "mongo") {
|
|
131
|
+
const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
|
|
132
|
+
if (!doc || doc.purpose !== "login")
|
|
133
|
+
return null;
|
|
134
|
+
return { userId: doc.userId, purpose: "login", emailOtpHash: doc.emailOtpHash, webauthnChallenge: doc.webauthnChallenge };
|
|
135
|
+
}
|
|
136
|
+
// redis
|
|
137
|
+
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
138
|
+
const raw = await getRedis().get(key);
|
|
139
|
+
if (!raw)
|
|
140
|
+
return null;
|
|
141
|
+
await getRedis().del(key);
|
|
142
|
+
const data = JSON.parse(raw);
|
|
143
|
+
if (data.purpose !== "login")
|
|
144
|
+
return null;
|
|
145
|
+
return { userId: data.userId, purpose: "login", emailOtpHash: data.emailOtpHash, webauthnChallenge: data.webauthnChallenge };
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Replace the email OTP hash on an existing challenge without consuming it.
|
|
149
|
+
* Used for the resend flow. Increments resendCount and caps the challenge lifetime.
|
|
150
|
+
* Returns { userId, resendCount } on success, null if challenge not found/expired/max resends reached.
|
|
151
|
+
*/
|
|
152
|
+
export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
|
|
153
|
+
const hash = sha256(token);
|
|
154
|
+
const ttl = getMfaChallengeTtl();
|
|
155
|
+
if (_store === "memory") {
|
|
156
|
+
const entry = _memoryChallenges.get(hash);
|
|
157
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
158
|
+
_memoryChallenges.delete(hash);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (entry.resendCount >= MAX_RESENDS)
|
|
162
|
+
return null;
|
|
163
|
+
entry.emailOtpHash = newEmailOtpHash;
|
|
164
|
+
entry.resendCount++;
|
|
165
|
+
// Cap lifetime: min(now + ttl, createdAt + ttl * 3)
|
|
166
|
+
const maxExpiry = entry.createdAt + ttl * 3 * 1000;
|
|
167
|
+
entry.expiresAt = Math.min(Date.now() + ttl * 1000, maxExpiry);
|
|
168
|
+
return { userId: entry.userId, resendCount: entry.resendCount };
|
|
169
|
+
}
|
|
170
|
+
if (_store === "sqlite") {
|
|
171
|
+
ensureSqliteMfaTable();
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const existing = _sqliteDb.query("SELECT createdAt, resendCount FROM mfa_challenges WHERE token = ? AND expiresAt > ?").get(hash, now);
|
|
174
|
+
if (!existing || existing.resendCount >= MAX_RESENDS)
|
|
175
|
+
return null;
|
|
176
|
+
const newExpiry = Math.min(now + ttl * 1000, existing.createdAt + ttl * 3 * 1000);
|
|
177
|
+
const newCount = existing.resendCount + 1;
|
|
178
|
+
const row = _sqliteDb.query("UPDATE mfa_challenges SET emailOtpHash = ?, resendCount = ?, expiresAt = ? WHERE token = ? RETURNING userId").get(newEmailOtpHash, newCount, newExpiry, hash);
|
|
179
|
+
return row ? { userId: row.userId, resendCount: newCount } : null;
|
|
180
|
+
}
|
|
181
|
+
if (_store === "mongo") {
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const existing = await getMfaChallengeModel().findOne({
|
|
184
|
+
token: hash,
|
|
185
|
+
expiresAt: { $gt: now },
|
|
186
|
+
resendCount: { $lt: MAX_RESENDS },
|
|
187
|
+
});
|
|
188
|
+
if (!existing)
|
|
189
|
+
return null;
|
|
190
|
+
const newCount = existing.resendCount + 1;
|
|
191
|
+
const newExpiry = new Date(Math.min(Date.now() + ttl * 1000, existing.createdAt.getTime() + ttl * 3 * 1000));
|
|
192
|
+
existing.emailOtpHash = newEmailOtpHash;
|
|
193
|
+
existing.resendCount = newCount;
|
|
194
|
+
existing.expiresAt = newExpiry;
|
|
195
|
+
await existing.save();
|
|
196
|
+
return { userId: existing.userId, resendCount: newCount };
|
|
197
|
+
}
|
|
198
|
+
// redis
|
|
199
|
+
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
200
|
+
const raw = await getRedis().get(key);
|
|
201
|
+
if (!raw)
|
|
202
|
+
return null;
|
|
203
|
+
const data = JSON.parse(raw);
|
|
204
|
+
if (data.resendCount >= MAX_RESENDS)
|
|
205
|
+
return null;
|
|
206
|
+
data.emailOtpHash = newEmailOtpHash;
|
|
207
|
+
data.resendCount++;
|
|
208
|
+
// Cap lifetime
|
|
209
|
+
const maxExpiry = data.createdAt + ttl * 3 * 1000;
|
|
210
|
+
const newExpiry = Math.min(Date.now() + ttl * 1000, maxExpiry);
|
|
211
|
+
const remainingTtl = Math.max(1, Math.ceil((newExpiry - Date.now()) / 1000));
|
|
212
|
+
await getRedis().set(key, JSON.stringify(data), "EX", remainingTtl);
|
|
213
|
+
return { userId: data.userId, resendCount: data.resendCount };
|
|
214
|
+
};
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// WebAuthn registration challenge helpers
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/**
|
|
219
|
+
* Create a WebAuthn registration challenge token. Separate from the login flow —
|
|
220
|
+
* uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
|
|
221
|
+
*/
|
|
222
|
+
export const createWebAuthnRegistrationChallenge = async (userId, challenge) => {
|
|
223
|
+
const token = crypto.randomUUID();
|
|
224
|
+
const hash = sha256(token);
|
|
225
|
+
const ttl = getMfaChallengeTtl();
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const purpose = "webauthn-registration";
|
|
228
|
+
if (_store === "memory") {
|
|
229
|
+
_memoryChallenges.set(hash, { userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
|
|
230
|
+
return token;
|
|
231
|
+
}
|
|
232
|
+
if (_store === "sqlite") {
|
|
233
|
+
ensureSqliteMfaTable();
|
|
234
|
+
_sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, challenge, now, now + ttl * 1000]);
|
|
235
|
+
return token;
|
|
236
|
+
}
|
|
237
|
+
if (_store === "mongo") {
|
|
238
|
+
await getMfaChallengeModel().create({
|
|
239
|
+
token: hash,
|
|
240
|
+
userId,
|
|
241
|
+
purpose,
|
|
242
|
+
webauthnChallenge: challenge,
|
|
243
|
+
createdAt: new Date(now),
|
|
244
|
+
resendCount: 0,
|
|
245
|
+
expiresAt: new Date(now + ttl * 1000),
|
|
246
|
+
});
|
|
247
|
+
return token;
|
|
248
|
+
}
|
|
249
|
+
// redis
|
|
250
|
+
await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0 }), "EX", ttl);
|
|
251
|
+
return token;
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Consume a WebAuthn registration challenge token.
|
|
255
|
+
* Only accepts tokens with `purpose: "webauthn-registration"`.
|
|
256
|
+
*/
|
|
257
|
+
export const consumeWebAuthnRegistrationChallenge = async (token) => {
|
|
258
|
+
const hash = sha256(token);
|
|
259
|
+
if (_store === "memory") {
|
|
260
|
+
const entry = _memoryChallenges.get(hash);
|
|
261
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
262
|
+
_memoryChallenges.delete(hash);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
_memoryChallenges.delete(hash);
|
|
266
|
+
if (entry.purpose !== "webauthn-registration" || !entry.webauthnChallenge)
|
|
267
|
+
return null;
|
|
268
|
+
return { userId: entry.userId, challenge: entry.webauthnChallenge };
|
|
269
|
+
}
|
|
270
|
+
if (_store === "sqlite") {
|
|
271
|
+
ensureSqliteMfaTable();
|
|
272
|
+
const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, purpose, webauthnChallenge").get(hash, Date.now());
|
|
273
|
+
if (!row || row.purpose !== "webauthn-registration" || !row.webauthnChallenge)
|
|
274
|
+
return null;
|
|
275
|
+
return { userId: row.userId, challenge: row.webauthnChallenge };
|
|
276
|
+
}
|
|
277
|
+
if (_store === "mongo") {
|
|
278
|
+
const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
|
|
279
|
+
if (!doc || doc.purpose !== "webauthn-registration" || !doc.webauthnChallenge)
|
|
280
|
+
return null;
|
|
281
|
+
return { userId: doc.userId, challenge: doc.webauthnChallenge };
|
|
282
|
+
}
|
|
283
|
+
// redis
|
|
284
|
+
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
285
|
+
const raw = await getRedis().get(key);
|
|
286
|
+
if (!raw)
|
|
287
|
+
return null;
|
|
288
|
+
await getRedis().del(key);
|
|
289
|
+
const data = JSON.parse(raw);
|
|
290
|
+
if (data.purpose !== "webauthn-registration" || !data.webauthnChallenge)
|
|
291
|
+
return null;
|
|
292
|
+
return { userId: data.userId, challenge: data.webauthnChallenge };
|
|
293
|
+
};
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
|
|
1
|
+
import { Google, Apple, MicrosoftEntraId, GitHub, generateState, generateCodeVerifier } from "arctic";
|
|
2
2
|
export type OAuthProviderConfig = {
|
|
3
3
|
google?: {
|
|
4
4
|
clientId: string;
|
|
@@ -12,10 +12,23 @@ export type OAuthProviderConfig = {
|
|
|
12
12
|
privateKey: string;
|
|
13
13
|
redirectUri: string;
|
|
14
14
|
};
|
|
15
|
+
microsoft?: {
|
|
16
|
+
tenantId: string;
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
};
|
|
21
|
+
github?: {
|
|
22
|
+
clientId: string;
|
|
23
|
+
clientSecret: string;
|
|
24
|
+
redirectUri: string;
|
|
25
|
+
};
|
|
15
26
|
};
|
|
16
27
|
export declare const initOAuthProviders: (config: OAuthProviderConfig) => void;
|
|
17
28
|
export declare const getGoogle: () => Google;
|
|
18
29
|
export declare const getApple: () => Apple;
|
|
30
|
+
export declare const getMicrosoft: () => MicrosoftEntraId;
|
|
31
|
+
export declare const getGitHub: () => GitHub;
|
|
19
32
|
export declare const getConfiguredOAuthProviders: () => string[];
|
|
20
33
|
type OAuthStateStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
21
34
|
export declare const setOAuthStateStore: (store: OAuthStateStore) => void;
|
package/dist/lib/oauth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
|
|
1
|
+
import { Google, Apple, MicrosoftEntraId, GitHub, generateState, generateCodeVerifier } from "arctic";
|
|
2
2
|
import { getRedis } from "./redis";
|
|
3
3
|
import { appConnection, mongoose } from "./mongo";
|
|
4
4
|
import { getAppName } from "./appConfig";
|
|
@@ -14,6 +14,14 @@ export const initOAuthProviders = (config) => {
|
|
|
14
14
|
const { clientId, teamId, keyId, privateKey, redirectUri } = config.apple;
|
|
15
15
|
_providers.apple = new Apple(clientId, teamId, keyId, new TextEncoder().encode(privateKey), redirectUri);
|
|
16
16
|
}
|
|
17
|
+
if (config.microsoft) {
|
|
18
|
+
const { tenantId, clientId, clientSecret, redirectUri } = config.microsoft;
|
|
19
|
+
_providers.microsoft = new MicrosoftEntraId(tenantId, clientId, clientSecret, redirectUri);
|
|
20
|
+
}
|
|
21
|
+
if (config.github) {
|
|
22
|
+
const { clientId, clientSecret, redirectUri } = config.github;
|
|
23
|
+
_providers.github = new GitHub(clientId, clientSecret, redirectUri);
|
|
24
|
+
}
|
|
17
25
|
};
|
|
18
26
|
export const getGoogle = () => {
|
|
19
27
|
if (!_providers.google)
|
|
@@ -25,6 +33,16 @@ export const getApple = () => {
|
|
|
25
33
|
throw new Error("Apple OAuth not configured");
|
|
26
34
|
return _providers.apple;
|
|
27
35
|
};
|
|
36
|
+
export const getMicrosoft = () => {
|
|
37
|
+
if (!_providers.microsoft)
|
|
38
|
+
throw new Error("Microsoft Entra ID OAuth not configured");
|
|
39
|
+
return _providers.microsoft;
|
|
40
|
+
};
|
|
41
|
+
export const getGitHub = () => {
|
|
42
|
+
if (!_providers.github)
|
|
43
|
+
throw new Error("GitHub OAuth not configured");
|
|
44
|
+
return _providers.github;
|
|
45
|
+
};
|
|
28
46
|
export const getConfiguredOAuthProviders = () => Object.entries(_providers)
|
|
29
47
|
.filter(([, v]) => v != null)
|
|
30
48
|
.map(([k]) => k);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type OAuthCodeStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
|
+
export declare const setOAuthCodeStore: (store: OAuthCodeStore) => void;
|
|
3
|
+
export interface OAuthCodePayload {
|
|
4
|
+
token: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
refreshToken?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
10
|
+
* Only the SHA-256 hash is persisted. */
|
|
11
|
+
export declare const storeOAuthCode: (payload: OAuthCodePayload) => Promise<string>;
|
|
12
|
+
/** Atomically consume an authorization code — returns its payload and deletes it.
|
|
13
|
+
* Returns null if invalid, expired, or already used. */
|
|
14
|
+
export declare const consumeOAuthCode: (code: string) => Promise<OAuthCodePayload | null>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
import { sha256 } from "./crypto";
|
|
5
|
+
import { memoryStoreOAuthCode, memoryConsumeOAuthCode, } from "../adapters/memoryAuth";
|
|
6
|
+
import { sqliteStoreOAuthCode, sqliteConsumeOAuthCode, } from "../adapters/sqliteAuth";
|
|
7
|
+
function getOAuthCodeModel() {
|
|
8
|
+
if (appConnection.models["OAuthCode"])
|
|
9
|
+
return appConnection.models["OAuthCode"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const schema = new Schema({
|
|
12
|
+
codeHash: { type: String, required: true, unique: true },
|
|
13
|
+
token: { type: String, required: true },
|
|
14
|
+
userId: { type: String, required: true },
|
|
15
|
+
email: { type: String },
|
|
16
|
+
refreshToken: { type: String },
|
|
17
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
18
|
+
}, { collection: "oauth_codes" });
|
|
19
|
+
return appConnection.model("OAuthCode", schema);
|
|
20
|
+
}
|
|
21
|
+
let _store = "redis";
|
|
22
|
+
export const setOAuthCodeStore = (store) => { _store = store; };
|
|
23
|
+
const CODE_TTL = 60; // 60 seconds
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Public API
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
28
|
+
* Only the SHA-256 hash is persisted. */
|
|
29
|
+
export const storeOAuthCode = async (payload) => {
|
|
30
|
+
const code = crypto.randomUUID();
|
|
31
|
+
const hash = sha256(code);
|
|
32
|
+
if (_store === "memory") {
|
|
33
|
+
memoryStoreOAuthCode(hash, payload, CODE_TTL);
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
if (_store === "sqlite") {
|
|
37
|
+
sqliteStoreOAuthCode(hash, payload, CODE_TTL);
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
if (_store === "mongo") {
|
|
41
|
+
await getOAuthCodeModel().create({
|
|
42
|
+
codeHash: hash,
|
|
43
|
+
...payload,
|
|
44
|
+
expiresAt: new Date(Date.now() + CODE_TTL * 1000),
|
|
45
|
+
});
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
// Redis
|
|
49
|
+
await getRedis().set(`oauthcode:${getAppName()}:${hash}`, JSON.stringify(payload), "EX", CODE_TTL);
|
|
50
|
+
return code;
|
|
51
|
+
};
|
|
52
|
+
/** Atomically consume an authorization code — returns its payload and deletes it.
|
|
53
|
+
* Returns null if invalid, expired, or already used. */
|
|
54
|
+
export const consumeOAuthCode = async (code) => {
|
|
55
|
+
const hash = sha256(code);
|
|
56
|
+
if (_store === "memory")
|
|
57
|
+
return memoryConsumeOAuthCode(hash);
|
|
58
|
+
if (_store === "sqlite")
|
|
59
|
+
return sqliteConsumeOAuthCode(hash);
|
|
60
|
+
if (_store === "mongo") {
|
|
61
|
+
const doc = await getOAuthCodeModel()
|
|
62
|
+
.findOneAndDelete({ codeHash: hash, expiresAt: { $gt: new Date() } })
|
|
63
|
+
.lean();
|
|
64
|
+
if (!doc)
|
|
65
|
+
return null;
|
|
66
|
+
return { token: doc.token, userId: doc.userId, email: doc.email, refreshToken: doc.refreshToken };
|
|
67
|
+
}
|
|
68
|
+
// Redis
|
|
69
|
+
const key = `oauthcode:${getAppName()}:${hash}`;
|
|
70
|
+
const redis = getRedis();
|
|
71
|
+
let raw = null;
|
|
72
|
+
if (typeof redis.getdel === "function") {
|
|
73
|
+
try {
|
|
74
|
+
raw = await redis.getdel(key);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
raw = await redis.get(key);
|
|
78
|
+
if (raw)
|
|
79
|
+
await redis.del(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
raw = await redis.get(key);
|
|
84
|
+
if (raw)
|
|
85
|
+
await redis.del(key);
|
|
86
|
+
}
|
|
87
|
+
if (!raw)
|
|
88
|
+
return null;
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
};
|
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
|
+
};
|
|
@@ -1,24 +1,20 @@
|
|
|
1
|
-
import { createHash } from "crypto";
|
|
2
1
|
import { getRedis } from "./redis";
|
|
3
|
-
import { appConnection } from "./mongo";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
4
3
|
import { getAppName, getResetTokenExpiry } from "./appConfig";
|
|
5
|
-
import { Schema } from "mongoose";
|
|
6
4
|
import { sqliteCreateResetToken, sqliteConsumeResetToken, } from "../adapters/sqliteAuth";
|
|
7
5
|
import { memoryCreateResetToken, memoryConsumeResetToken, } from "../adapters/memoryAuth";
|
|
8
|
-
|
|
9
|
-
// Token hashing — store SHA-256(token); raw token is only in the email link.
|
|
10
|
-
// If the store is ever leaked, outstanding tokens cannot be replayed directly.
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
const hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
13
|
-
const resetSchema = new Schema({
|
|
14
|
-
token: { type: String, required: true, unique: true },
|
|
15
|
-
userId: { type: String, required: true },
|
|
16
|
-
email: { type: String, required: true },
|
|
17
|
-
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
18
|
-
}, { collection: "password_resets" });
|
|
6
|
+
import { sha256 as hashToken } from "./crypto";
|
|
19
7
|
function getResetModel() {
|
|
20
|
-
|
|
21
|
-
appConnection.
|
|
8
|
+
if (appConnection.models["PasswordReset"])
|
|
9
|
+
return appConnection.models["PasswordReset"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const resetSchema = new Schema({
|
|
12
|
+
token: { type: String, required: true, unique: true },
|
|
13
|
+
userId: { type: String, required: true },
|
|
14
|
+
email: { type: String, required: true },
|
|
15
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
16
|
+
}, { collection: "password_resets" });
|
|
17
|
+
return appConnection.model("PasswordReset", resetSchema);
|
|
22
18
|
}
|
|
23
19
|
// ---------------------------------------------------------------------------
|
|
24
20
|
// Redis helpers
|
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>;
|