@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.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. 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
+ };