@lastshotlabs/bunshot 0.0.21 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -25,13 +25,35 @@ function initSchema(db) {
|
|
|
25
25
|
passwordHash TEXT,
|
|
26
26
|
providerIds TEXT NOT NULL DEFAULT '[]',
|
|
27
27
|
roles TEXT NOT NULL DEFAULT '[]',
|
|
28
|
-
emailVerified INTEGER NOT NULL DEFAULT 0
|
|
28
|
+
emailVerified INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
displayName TEXT,
|
|
30
|
+
firstName TEXT,
|
|
31
|
+
lastName TEXT,
|
|
32
|
+
externalId TEXT,
|
|
33
|
+
suspended INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
suspendedAt TEXT,
|
|
35
|
+
suspendedReason TEXT
|
|
29
36
|
)`);
|
|
30
37
|
// Add emailVerified to pre-existing databases that lack the column
|
|
31
38
|
try {
|
|
32
39
|
db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
|
|
33
40
|
}
|
|
34
41
|
catch { /* already exists */ }
|
|
42
|
+
// Add profile and suspension columns to pre-existing databases
|
|
43
|
+
for (const col of [
|
|
44
|
+
"ALTER TABLE users ADD COLUMN displayName TEXT",
|
|
45
|
+
"ALTER TABLE users ADD COLUMN firstName TEXT",
|
|
46
|
+
"ALTER TABLE users ADD COLUMN lastName TEXT",
|
|
47
|
+
"ALTER TABLE users ADD COLUMN externalId TEXT",
|
|
48
|
+
"ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0",
|
|
49
|
+
"ALTER TABLE users ADD COLUMN suspendedAt TEXT",
|
|
50
|
+
"ALTER TABLE users ADD COLUMN suspendedReason TEXT",
|
|
51
|
+
]) {
|
|
52
|
+
try {
|
|
53
|
+
db.run(col);
|
|
54
|
+
}
|
|
55
|
+
catch { /* column already exists */ }
|
|
56
|
+
}
|
|
35
57
|
// Add MFA columns to pre-existing databases
|
|
36
58
|
try {
|
|
37
59
|
db.run("ALTER TABLE users ADD COLUMN mfaSecret TEXT");
|
|
@@ -78,6 +100,14 @@ function initSchema(db) {
|
|
|
78
100
|
db.run("ALTER TABLE sessions ADD COLUMN prevTokenExpiresAt INTEGER");
|
|
79
101
|
}
|
|
80
102
|
catch { /* already exists */ }
|
|
103
|
+
try {
|
|
104
|
+
db.run("ALTER TABLE sessions ADD COLUMN fingerprint TEXT");
|
|
105
|
+
}
|
|
106
|
+
catch { /* already exists */ }
|
|
107
|
+
try {
|
|
108
|
+
db.run("ALTER TABLE sessions ADD COLUMN mfaVerifiedAt INTEGER");
|
|
109
|
+
}
|
|
110
|
+
catch { /* already exists */ }
|
|
81
111
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_refreshToken ON sessions(refreshToken) WHERE refreshToken IS NOT NULL");
|
|
82
112
|
db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
|
|
83
113
|
state TEXT PRIMARY KEY,
|
|
@@ -119,6 +149,35 @@ function initSchema(db) {
|
|
|
119
149
|
createdAt INTEGER NOT NULL
|
|
120
150
|
)`);
|
|
121
151
|
db.run("CREATE INDEX IF NOT EXISTS idx_webauthn_userId ON webauthn_credentials(userId)");
|
|
152
|
+
db.run(`CREATE TABLE IF NOT EXISTS groups (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
name TEXT NOT NULL,
|
|
155
|
+
displayName TEXT,
|
|
156
|
+
description TEXT,
|
|
157
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
158
|
+
tenantId TEXT,
|
|
159
|
+
createdAt INTEGER NOT NULL,
|
|
160
|
+
updatedAt INTEGER NOT NULL
|
|
161
|
+
)`);
|
|
162
|
+
// SQLite UNIQUE treats each NULL as distinct, so we use partial indexes instead of
|
|
163
|
+
// a simple UNIQUE constraint on (name, tenantId). This correctly enforces name
|
|
164
|
+
// uniqueness within app-wide scope and within each tenant scope separately.
|
|
165
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_appwide ON groups(name) WHERE tenantId IS NULL");
|
|
166
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_tenant ON groups(name, tenantId) WHERE tenantId IS NOT NULL");
|
|
167
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_groups_tenantId ON groups(tenantId)");
|
|
168
|
+
db.run(`CREATE TABLE IF NOT EXISTS group_memberships (
|
|
169
|
+
userId TEXT NOT NULL,
|
|
170
|
+
groupId TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
171
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
172
|
+
tenantId TEXT,
|
|
173
|
+
createdAt INTEGER NOT NULL,
|
|
174
|
+
PRIMARY KEY (userId, groupId)
|
|
175
|
+
)`);
|
|
176
|
+
// NOTE: PRAGMA foreign_keys = ON is set in setSqliteDb() and must run per-connection.
|
|
177
|
+
// If any code path opens SQLite without going through setSqliteDb, ON DELETE CASCADE
|
|
178
|
+
// for group_memberships will silently not fire. All SQLite access must use setSqliteDb.
|
|
179
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_gm_groupId ON group_memberships(groupId)");
|
|
180
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_gm_userId_tenantId ON group_memberships(userId, tenantId)");
|
|
122
181
|
db.run(`CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
123
182
|
codeHash TEXT PRIMARY KEY,
|
|
124
183
|
token TEXT NOT NULL,
|
|
@@ -127,6 +186,20 @@ function initSchema(db) {
|
|
|
127
186
|
refreshToken TEXT,
|
|
128
187
|
expiresAt INTEGER NOT NULL
|
|
129
188
|
)`);
|
|
189
|
+
db.run(`CREATE TABLE IF NOT EXISTS deletion_cancel_tokens (
|
|
190
|
+
token TEXT PRIMARY KEY,
|
|
191
|
+
userId TEXT NOT NULL,
|
|
192
|
+
jobId TEXT NOT NULL,
|
|
193
|
+
expiresAt INTEGER NOT NULL
|
|
194
|
+
)`);
|
|
195
|
+
db.run(`CREATE TABLE IF NOT EXISTS upload_registry (
|
|
196
|
+
key TEXT PRIMARY KEY,
|
|
197
|
+
ownerUserId TEXT,
|
|
198
|
+
tenantId TEXT,
|
|
199
|
+
mimeType TEXT,
|
|
200
|
+
bucket TEXT,
|
|
201
|
+
createdAt INTEGER NOT NULL
|
|
202
|
+
)`);
|
|
130
203
|
}
|
|
131
204
|
// ---------------------------------------------------------------------------
|
|
132
205
|
// Auth adapter
|
|
@@ -205,13 +278,19 @@ export const sqliteAuthAdapter = {
|
|
|
205
278
|
db.run("UPDATE users SET roles = ? WHERE id = ?", [JSON.stringify(roles.filter((r) => r !== role)), userId]);
|
|
206
279
|
},
|
|
207
280
|
async getUser(userId) {
|
|
208
|
-
const row = getDb().query("SELECT email, providerIds, emailVerified FROM users WHERE id = ?").get(userId);
|
|
281
|
+
const row = getDb().query("SELECT email, providerIds, emailVerified, displayName, firstName, lastName, externalId, suspended, suspendedReason FROM users WHERE id = ?").get(userId);
|
|
209
282
|
if (!row)
|
|
210
283
|
return null;
|
|
211
284
|
return {
|
|
212
285
|
email: row.email ?? undefined,
|
|
213
286
|
providerIds: JSON.parse(row.providerIds),
|
|
214
287
|
emailVerified: row.emailVerified === 1,
|
|
288
|
+
displayName: row.displayName ?? undefined,
|
|
289
|
+
firstName: row.firstName ?? undefined,
|
|
290
|
+
lastName: row.lastName ?? undefined,
|
|
291
|
+
externalId: row.externalId ?? undefined,
|
|
292
|
+
suspended: row.suspended === 1,
|
|
293
|
+
suspendedReason: row.suspendedReason ?? undefined,
|
|
215
294
|
};
|
|
216
295
|
},
|
|
217
296
|
async unlinkProvider(userId, provider) {
|
|
@@ -325,6 +404,227 @@ export const sqliteAuthAdapter = {
|
|
|
325
404
|
async removeTenantRole(userId, tenantId, role) {
|
|
326
405
|
getDb().run("DELETE FROM tenant_roles WHERE userId = ? AND tenantId = ? AND role = ?", [userId, tenantId, role]);
|
|
327
406
|
},
|
|
407
|
+
async setSuspended(userId, suspended, reason) {
|
|
408
|
+
if (suspended) {
|
|
409
|
+
getDb().run("UPDATE users SET suspended = 1, suspendedAt = ?, suspendedReason = ? WHERE id = ?", [new Date().toISOString(), reason ?? null, userId]);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
getDb().run("UPDATE users SET suspended = 0, suspendedAt = NULL, suspendedReason = NULL WHERE id = ?", [userId]);
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
async getSuspended(userId) {
|
|
416
|
+
const row = getDb().query("SELECT suspended, suspendedReason FROM users WHERE id = ?").get(userId);
|
|
417
|
+
if (!row)
|
|
418
|
+
return null;
|
|
419
|
+
return { suspended: row.suspended === 1, suspendedReason: row.suspendedReason ?? undefined };
|
|
420
|
+
},
|
|
421
|
+
async updateProfile(userId, fields) {
|
|
422
|
+
const sets = [];
|
|
423
|
+
const params = [];
|
|
424
|
+
if ("displayName" in fields) {
|
|
425
|
+
sets.push("displayName = ?");
|
|
426
|
+
params.push(fields.displayName ?? null);
|
|
427
|
+
}
|
|
428
|
+
if ("firstName" in fields) {
|
|
429
|
+
sets.push("firstName = ?");
|
|
430
|
+
params.push(fields.firstName ?? null);
|
|
431
|
+
}
|
|
432
|
+
if ("lastName" in fields) {
|
|
433
|
+
sets.push("lastName = ?");
|
|
434
|
+
params.push(fields.lastName ?? null);
|
|
435
|
+
}
|
|
436
|
+
if ("externalId" in fields) {
|
|
437
|
+
sets.push("externalId = ?");
|
|
438
|
+
params.push(fields.externalId ?? null);
|
|
439
|
+
}
|
|
440
|
+
if (sets.length === 0)
|
|
441
|
+
return;
|
|
442
|
+
params.push(userId);
|
|
443
|
+
getDb().run(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
444
|
+
},
|
|
445
|
+
async listUsers(query) {
|
|
446
|
+
const db = getDb();
|
|
447
|
+
const conditions = [];
|
|
448
|
+
const params = [];
|
|
449
|
+
if (query.email !== undefined) {
|
|
450
|
+
conditions.push("email = ?");
|
|
451
|
+
params.push(query.email);
|
|
452
|
+
}
|
|
453
|
+
if (query.externalId !== undefined) {
|
|
454
|
+
conditions.push("externalId = ?");
|
|
455
|
+
params.push(query.externalId);
|
|
456
|
+
}
|
|
457
|
+
if (query.suspended !== undefined) {
|
|
458
|
+
conditions.push("suspended = ?");
|
|
459
|
+
params.push(query.suspended ? 1 : 0);
|
|
460
|
+
}
|
|
461
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
462
|
+
const startIndex = query.startIndex ?? 0;
|
|
463
|
+
const count = query.count ?? 100;
|
|
464
|
+
const queryParams = [...params, count, startIndex];
|
|
465
|
+
const countParams = params;
|
|
466
|
+
const rows = db.prepare(`SELECT id, email, displayName, firstName, lastName, externalId, suspended, suspendedAt, suspendedReason, emailVerified, providerIds FROM users ${where} LIMIT ? OFFSET ?`).all(...queryParams);
|
|
467
|
+
const totalRow = db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...countParams);
|
|
468
|
+
const totalResults = totalRow?.c ?? 0;
|
|
469
|
+
return {
|
|
470
|
+
users: rows.map((r) => ({
|
|
471
|
+
id: r.id,
|
|
472
|
+
email: r.email ?? undefined,
|
|
473
|
+
displayName: r.displayName ?? undefined,
|
|
474
|
+
firstName: r.firstName ?? undefined,
|
|
475
|
+
lastName: r.lastName ?? undefined,
|
|
476
|
+
externalId: r.externalId ?? undefined,
|
|
477
|
+
suspended: r.suspended === 1,
|
|
478
|
+
suspendedAt: r.suspendedAt ? new Date(r.suspendedAt) : undefined,
|
|
479
|
+
suspendedReason: r.suspendedReason ?? undefined,
|
|
480
|
+
emailVerified: r.emailVerified === 1,
|
|
481
|
+
providerIds: JSON.parse(r.providerIds),
|
|
482
|
+
})),
|
|
483
|
+
totalResults,
|
|
484
|
+
};
|
|
485
|
+
},
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Groups
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
async createGroup(group) {
|
|
490
|
+
const id = crypto.randomUUID();
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
try {
|
|
493
|
+
getDb().run("INSERT INTO groups (id, name, displayName, description, roles, tenantId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, group.name, group.displayName ?? null, group.description ?? null, JSON.stringify(group.roles ?? []), group.tenantId ?? null, now, now]);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
if (err?.code === "SQLITE_CONSTRAINT_UNIQUE" || err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
497
|
+
throw new HttpError(409, "A group with this name already exists in this scope");
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
return { id };
|
|
502
|
+
},
|
|
503
|
+
async deleteGroup(groupId) {
|
|
504
|
+
// group_memberships are cascade-deleted via ON DELETE CASCADE (requires PRAGMA foreign_keys = ON)
|
|
505
|
+
getDb().run("DELETE FROM groups WHERE id = ?", [groupId]);
|
|
506
|
+
},
|
|
507
|
+
async getGroup(groupId) {
|
|
508
|
+
const row = getDb().query("SELECT id, name, displayName, description, roles, tenantId, createdAt, updatedAt FROM groups WHERE id = ?").get(groupId);
|
|
509
|
+
if (!row)
|
|
510
|
+
return null;
|
|
511
|
+
return { ...row, displayName: row.displayName ?? undefined, description: row.description ?? undefined, roles: JSON.parse(row.roles) };
|
|
512
|
+
},
|
|
513
|
+
async listGroups(tenantId, opts) {
|
|
514
|
+
const limit = Math.min(opts?.limit ?? 50, 200);
|
|
515
|
+
const offset = opts?.offset ?? 0;
|
|
516
|
+
const db = getDb();
|
|
517
|
+
const cols = "id, name, displayName, description, roles, tenantId, createdAt, updatedAt";
|
|
518
|
+
let rows;
|
|
519
|
+
let total;
|
|
520
|
+
if (tenantId === null) {
|
|
521
|
+
rows = db.query(`SELECT ${cols} FROM groups WHERE tenantId IS NULL LIMIT ? OFFSET ?`).all(limit, offset);
|
|
522
|
+
total = (db.query("SELECT COUNT(*) as c FROM groups WHERE tenantId IS NULL").get()?.c ?? 0);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
rows = db.query(`SELECT ${cols} FROM groups WHERE tenantId = ? LIMIT ? OFFSET ?`).all(tenantId, limit, offset);
|
|
526
|
+
total = (db.query("SELECT COUNT(*) as c FROM groups WHERE tenantId = ?").get(tenantId)?.c ?? 0);
|
|
527
|
+
}
|
|
528
|
+
const items = rows.map((r) => ({ ...r, displayName: r.displayName ?? undefined, description: r.description ?? undefined, roles: JSON.parse(r.roles) }));
|
|
529
|
+
return { items, total, limit, offset };
|
|
530
|
+
},
|
|
531
|
+
async updateGroup(groupId, updates) {
|
|
532
|
+
const db = getDb();
|
|
533
|
+
const now = Date.now();
|
|
534
|
+
const sets = ["updatedAt = ?"];
|
|
535
|
+
const params = [now];
|
|
536
|
+
if (updates.name !== undefined) {
|
|
537
|
+
sets.push("name = ?");
|
|
538
|
+
params.push(updates.name);
|
|
539
|
+
}
|
|
540
|
+
if ("displayName" in updates) {
|
|
541
|
+
sets.push("displayName = ?");
|
|
542
|
+
params.push(updates.displayName ?? null);
|
|
543
|
+
}
|
|
544
|
+
if ("description" in updates) {
|
|
545
|
+
sets.push("description = ?");
|
|
546
|
+
params.push(updates.description ?? null);
|
|
547
|
+
}
|
|
548
|
+
if (updates.roles !== undefined) {
|
|
549
|
+
sets.push("roles = ?");
|
|
550
|
+
params.push(JSON.stringify(updates.roles));
|
|
551
|
+
}
|
|
552
|
+
params.push(groupId);
|
|
553
|
+
db.run(`UPDATE groups SET ${sets.join(", ")} WHERE id = ?`, params);
|
|
554
|
+
},
|
|
555
|
+
async addGroupMember(groupId, userId, roles = []) {
|
|
556
|
+
const group = getDb().query("SELECT tenantId FROM groups WHERE id = ?").get(groupId);
|
|
557
|
+
if (!group)
|
|
558
|
+
throw new HttpError(404, "Group not found");
|
|
559
|
+
try {
|
|
560
|
+
getDb().run("INSERT INTO group_memberships (userId, groupId, roles, tenantId, createdAt) VALUES (?, ?, ?, ?, ?)", [userId, groupId, JSON.stringify(roles), group.tenantId ?? null, Date.now()]);
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY" || err?.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
564
|
+
throw new HttpError(409, "User is already a member of this group");
|
|
565
|
+
}
|
|
566
|
+
throw err;
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
async updateGroupMembership(groupId, userId, roles) {
|
|
570
|
+
getDb().run("UPDATE group_memberships SET roles = ? WHERE userId = ? AND groupId = ?", [JSON.stringify(roles), userId, groupId]);
|
|
571
|
+
},
|
|
572
|
+
async removeGroupMember(groupId, userId) {
|
|
573
|
+
getDb().run("DELETE FROM group_memberships WHERE userId = ? AND groupId = ?", [userId, groupId]);
|
|
574
|
+
},
|
|
575
|
+
async getGroupMembers(groupId, opts) {
|
|
576
|
+
const limit = Math.min(opts?.limit ?? 50, 200);
|
|
577
|
+
const offset = opts?.offset ?? 0;
|
|
578
|
+
const db = getDb();
|
|
579
|
+
const rows = db.query("SELECT userId, roles FROM group_memberships WHERE groupId = ? LIMIT ? OFFSET ?").all(groupId, limit, offset);
|
|
580
|
+
const total = db.query("SELECT COUNT(*) as c FROM group_memberships WHERE groupId = ?").get(groupId)?.c ?? 0;
|
|
581
|
+
return { items: rows.map((r) => ({ userId: r.userId, roles: JSON.parse(r.roles) })), total, limit, offset };
|
|
582
|
+
},
|
|
583
|
+
async getUserGroups(userId, tenantId) {
|
|
584
|
+
const db = getDb();
|
|
585
|
+
let memberRows;
|
|
586
|
+
if (tenantId === null) {
|
|
587
|
+
memberRows = db.query("SELECT groupId, roles as memberRoles FROM group_memberships WHERE userId = ? AND tenantId IS NULL").all(userId);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
memberRows = db.query("SELECT groupId, roles as memberRoles FROM group_memberships WHERE userId = ? AND tenantId = ?").all(userId, tenantId);
|
|
591
|
+
}
|
|
592
|
+
if (memberRows.length === 0)
|
|
593
|
+
return [];
|
|
594
|
+
const result = [];
|
|
595
|
+
for (const m of memberRows) {
|
|
596
|
+
const row = db.query("SELECT id, name, displayName, description, roles, tenantId, createdAt, updatedAt FROM groups WHERE id = ?").get(m.groupId);
|
|
597
|
+
if (row) {
|
|
598
|
+
result.push({
|
|
599
|
+
group: { ...row, displayName: row.displayName ?? undefined, description: row.description ?? undefined, roles: JSON.parse(row.roles) },
|
|
600
|
+
membershipRoles: JSON.parse(m.memberRoles),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
},
|
|
606
|
+
async getEffectiveRoles(userId, tenantId) {
|
|
607
|
+
const db = getDb();
|
|
608
|
+
// Direct roles
|
|
609
|
+
let direct = [];
|
|
610
|
+
if (tenantId) {
|
|
611
|
+
const rows = db.query("SELECT role FROM tenant_roles WHERE userId = ? AND tenantId = ?").all(userId, tenantId);
|
|
612
|
+
direct = rows.map((r) => r.role);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
const row = db.query("SELECT roles FROM users WHERE id = ?").get(userId);
|
|
616
|
+
direct = row ? JSON.parse(row.roles) : [];
|
|
617
|
+
}
|
|
618
|
+
let memberRows;
|
|
619
|
+
if (tenantId === null) {
|
|
620
|
+
memberRows = db.query("SELECT g.roles as groupRoles, gm.roles as memberRoles FROM group_memberships gm JOIN groups g ON g.id = gm.groupId WHERE gm.userId = ? AND gm.tenantId IS NULL").all(userId);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
memberRows = db.query("SELECT g.roles as groupRoles, gm.roles as memberRoles FROM group_memberships gm JOIN groups g ON g.id = gm.groupId WHERE gm.userId = ? AND gm.tenantId = ?").all(userId, tenantId);
|
|
624
|
+
}
|
|
625
|
+
const groupRoles = memberRows.flatMap((r) => [...JSON.parse(r.groupRoles), ...JSON.parse(r.memberRoles)]);
|
|
626
|
+
return [...new Set([...direct, ...groupRoles])];
|
|
627
|
+
},
|
|
328
628
|
};
|
|
329
629
|
import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
|
|
330
630
|
const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
|
|
@@ -413,6 +713,20 @@ export const sqliteRotateRefreshToken = (sessionId, newRefreshToken, newAccessTo
|
|
|
413
713
|
const prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
|
|
414
714
|
getDb().run("UPDATE sessions SET prevRefreshToken = refreshToken, prevTokenExpiresAt = ?, refreshToken = ?, token = ? WHERE sessionId = ?", [prevTokenExpiresAt, newRefreshToken, newAccessToken, sessionId]);
|
|
415
715
|
};
|
|
716
|
+
export const sqliteGetSessionFingerprint = (sessionId) => {
|
|
717
|
+
const row = getDb().query("SELECT fingerprint FROM sessions WHERE sessionId = ?").get(sessionId);
|
|
718
|
+
return row?.fingerprint ?? null;
|
|
719
|
+
};
|
|
720
|
+
export const sqliteSetSessionFingerprint = (sessionId, fingerprint) => {
|
|
721
|
+
getDb().run("UPDATE sessions SET fingerprint = ? WHERE sessionId = ?", [fingerprint, sessionId]);
|
|
722
|
+
};
|
|
723
|
+
export const sqliteGetMfaVerifiedAt = (sessionId) => {
|
|
724
|
+
const row = getDb().query("SELECT mfaVerifiedAt FROM sessions WHERE sessionId = ?").get(sessionId);
|
|
725
|
+
return row?.mfaVerifiedAt ?? null;
|
|
726
|
+
};
|
|
727
|
+
export const sqliteSetMfaVerifiedAt = (sessionId, ts) => {
|
|
728
|
+
getDb().run("UPDATE sessions SET mfaVerifiedAt = ? WHERE sessionId = ?", [ts, sessionId]);
|
|
729
|
+
};
|
|
416
730
|
// ---------------------------------------------------------------------------
|
|
417
731
|
// OAuth state helpers (used by src/lib/oauth.ts)
|
|
418
732
|
// ---------------------------------------------------------------------------
|
|
@@ -464,6 +778,10 @@ export const sqliteGetVerificationToken = (token) => {
|
|
|
464
778
|
export const sqliteDeleteVerificationToken = (token) => {
|
|
465
779
|
getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
|
|
466
780
|
};
|
|
781
|
+
export const sqliteConsumeVerificationToken = (token) => {
|
|
782
|
+
const row = getDb().query("DELETE FROM email_verifications WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(token, Date.now());
|
|
783
|
+
return row ?? null;
|
|
784
|
+
};
|
|
467
785
|
// ---------------------------------------------------------------------------
|
|
468
786
|
// Password reset token helpers (used by src/lib/resetPassword.ts)
|
|
469
787
|
// ---------------------------------------------------------------------------
|
|
@@ -475,6 +793,17 @@ export const sqliteConsumeResetToken = (hash) => {
|
|
|
475
793
|
const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
|
|
476
794
|
return row ?? null;
|
|
477
795
|
};
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
// Account deletion cancel token helpers (used by src/lib/deletionCancelToken.ts)
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
export const sqliteCreateDeletionCancelToken = (token, userId, jobId, ttlSeconds) => {
|
|
800
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
801
|
+
getDb().run("INSERT INTO deletion_cancel_tokens (token, userId, jobId, expiresAt) VALUES (?, ?, ?, ?)", [token, userId, jobId, expiresAt]);
|
|
802
|
+
};
|
|
803
|
+
export const sqliteConsumeDeletionCancelToken = (hash) => {
|
|
804
|
+
const row = getDb().query("DELETE FROM deletion_cancel_tokens WHERE token = ? AND expiresAt > ? RETURNING userId, jobId").get(hash, Date.now());
|
|
805
|
+
return row ?? null;
|
|
806
|
+
};
|
|
478
807
|
export const sqliteStoreOAuthCode = (hash, payload, ttlSeconds) => {
|
|
479
808
|
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
480
809
|
getDb().run("INSERT INTO oauth_codes (codeHash, token, userId, email, refreshToken, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", [hash, payload.token, payload.userId, payload.email ?? null, payload.refreshToken ?? null, expiresAt]);
|
|
@@ -506,3 +835,24 @@ export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
|
506
835
|
db.run("DELETE FROM oauth_codes WHERE expiresAt <= ?", [now]);
|
|
507
836
|
}, intervalMs);
|
|
508
837
|
};
|
|
838
|
+
export const sqliteRegisterUpload = (record) => {
|
|
839
|
+
getDb().run(`INSERT OR REPLACE INTO upload_registry (key, ownerUserId, tenantId, mimeType, bucket, createdAt)
|
|
840
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [record.key, record.ownerUserId ?? null, record.tenantId ?? null, record.mimeType ?? null, record.bucket ?? null, record.createdAt]);
|
|
841
|
+
};
|
|
842
|
+
export const sqliteGetUploadRecord = (key) => {
|
|
843
|
+
const row = getDb().query("SELECT key, ownerUserId, tenantId, mimeType, bucket, createdAt FROM upload_registry WHERE key = ?").get(key);
|
|
844
|
+
if (!row)
|
|
845
|
+
return null;
|
|
846
|
+
return {
|
|
847
|
+
key: row.key,
|
|
848
|
+
...(row.ownerUserId !== null ? { ownerUserId: row.ownerUserId } : {}),
|
|
849
|
+
...(row.tenantId !== null ? { tenantId: row.tenantId } : {}),
|
|
850
|
+
...(row.mimeType !== null ? { mimeType: row.mimeType } : {}),
|
|
851
|
+
...(row.bucket !== null ? { bucket: row.bucket } : {}),
|
|
852
|
+
createdAt: row.createdAt,
|
|
853
|
+
};
|
|
854
|
+
};
|
|
855
|
+
export const sqliteDeleteUploadRecord = (key) => {
|
|
856
|
+
const result = getDb().run("DELETE FROM upload_registry WHERE key = ?", [key]);
|
|
857
|
+
return result.changes > 0;
|
|
858
|
+
};
|