@lastshotlabs/bunshot 0.0.20 → 0.0.25

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 (122) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +44 -0
  4. package/dist/adapters/memoryAuth.d.ts +7 -0
  5. package/dist/adapters/memoryAuth.js +144 -0
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +120 -0
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +7 -0
  12. package/dist/adapters/sqliteAuth.js +199 -0
  13. package/dist/app.d.ts +100 -3
  14. package/dist/app.js +248 -47
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +49 -7
  17. package/dist/index.js +35 -5
  18. package/dist/lib/HttpError.d.ts +5 -0
  19. package/dist/lib/HttpError.js +7 -0
  20. package/dist/lib/appConfig.d.ts +44 -0
  21. package/dist/lib/appConfig.js +16 -0
  22. package/dist/lib/auditLog.d.ts +52 -0
  23. package/dist/lib/auditLog.js +201 -0
  24. package/dist/lib/authAdapter.d.ts +69 -0
  25. package/dist/lib/constants.d.ts +4 -0
  26. package/dist/lib/constants.js +4 -0
  27. package/dist/lib/context.d.ts +19 -1
  28. package/dist/lib/context.js +17 -3
  29. package/dist/lib/createRoute.d.ts +28 -2
  30. package/dist/lib/createRoute.js +54 -3
  31. package/dist/lib/deletionCancelToken.d.ts +12 -0
  32. package/dist/lib/deletionCancelToken.js +88 -0
  33. package/dist/lib/groups.d.ts +113 -0
  34. package/dist/lib/groups.js +133 -0
  35. package/dist/lib/idempotency.d.ts +22 -0
  36. package/dist/lib/idempotency.js +182 -0
  37. package/dist/lib/metrics.d.ts +14 -0
  38. package/dist/lib/metrics.js +158 -0
  39. package/dist/lib/pagination.d.ts +119 -0
  40. package/dist/lib/pagination.js +166 -0
  41. package/dist/lib/session.d.ts +4 -0
  42. package/dist/lib/session.js +56 -2
  43. package/dist/lib/signing.d.ts +52 -0
  44. package/dist/lib/signing.js +180 -0
  45. package/dist/lib/storageAdapter.d.ts +30 -0
  46. package/dist/lib/storageAdapter.js +1 -0
  47. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  48. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  49. package/dist/lib/tenant.js +2 -2
  50. package/dist/lib/upload.d.ts +35 -0
  51. package/dist/lib/upload.js +87 -0
  52. package/dist/lib/validate.js +2 -2
  53. package/dist/lib/ws.d.ts +1 -0
  54. package/dist/lib/ws.js +21 -0
  55. package/dist/lib/wsHeartbeat.d.ts +12 -0
  56. package/dist/lib/wsHeartbeat.js +57 -0
  57. package/dist/lib/wsMessages.d.ts +40 -0
  58. package/dist/lib/wsMessages.js +330 -0
  59. package/dist/lib/wsPresence.d.ts +25 -0
  60. package/dist/lib/wsPresence.js +99 -0
  61. package/dist/middleware/auditLog.d.ts +22 -0
  62. package/dist/middleware/auditLog.js +39 -0
  63. package/dist/middleware/cacheResponse.js +5 -1
  64. package/dist/middleware/csrf.js +10 -0
  65. package/dist/middleware/identify.js +57 -9
  66. package/dist/middleware/metrics.d.ts +9 -0
  67. package/dist/middleware/metrics.js +26 -0
  68. package/dist/middleware/requestId.d.ts +3 -0
  69. package/dist/middleware/requestId.js +7 -0
  70. package/dist/middleware/requestLogger.d.ts +38 -0
  71. package/dist/middleware/requestLogger.js +68 -0
  72. package/dist/middleware/requestSigning.d.ts +20 -0
  73. package/dist/middleware/requestSigning.js +99 -0
  74. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  75. package/dist/middleware/requireMfaSetup.js +36 -0
  76. package/dist/middleware/requireRole.d.ts +9 -3
  77. package/dist/middleware/requireRole.js +23 -36
  78. package/dist/middleware/upload.d.ts +5 -0
  79. package/dist/middleware/upload.js +27 -0
  80. package/dist/middleware/webhookAuth.d.ts +30 -0
  81. package/dist/middleware/webhookAuth.js +57 -0
  82. package/dist/models/AuditLog.d.ts +30 -0
  83. package/dist/models/AuditLog.js +39 -0
  84. package/dist/models/Group.d.ts +21 -0
  85. package/dist/models/Group.js +28 -0
  86. package/dist/models/GroupMembership.d.ts +21 -0
  87. package/dist/models/GroupMembership.js +25 -0
  88. package/dist/routes/auth.js +84 -6
  89. package/dist/routes/groups.d.ts +21 -0
  90. package/dist/routes/groups.js +346 -0
  91. package/dist/routes/jobs.js +47 -45
  92. package/dist/routes/metrics.d.ts +7 -0
  93. package/dist/routes/metrics.js +52 -0
  94. package/dist/routes/mfa.js +4 -0
  95. package/dist/routes/uploads.d.ts +2 -0
  96. package/dist/routes/uploads.js +135 -0
  97. package/dist/server.d.ts +26 -0
  98. package/dist/server.js +46 -3
  99. package/dist/ws/index.js +3 -0
  100. package/docs/sections/auth-flow/full.md +779 -634
  101. package/docs/sections/auth-flow/overview.md +2 -2
  102. package/docs/sections/auth-security-examples/full.md +365 -0
  103. package/docs/sections/authentication/full.md +130 -0
  104. package/docs/sections/authentication/overview.md +5 -0
  105. package/docs/sections/cli/full.md +13 -1
  106. package/docs/sections/configuration/full.md +17 -0
  107. package/docs/sections/configuration/overview.md +1 -0
  108. package/docs/sections/exports/full.md +34 -3
  109. package/docs/sections/logging/full.md +83 -0
  110. package/docs/sections/metrics/full.md +127 -0
  111. package/docs/sections/oauth/full.md +189 -189
  112. package/docs/sections/oauth/overview.md +1 -1
  113. package/docs/sections/pagination/full.md +93 -0
  114. package/docs/sections/roles/full.md +224 -135
  115. package/docs/sections/roles/overview.md +3 -1
  116. package/docs/sections/signing/full.md +203 -0
  117. package/docs/sections/uploads/full.md +199 -0
  118. package/docs/sections/versioning/full.md +85 -0
  119. package/docs/sections/webhook-auth/full.md +100 -0
  120. package/docs/sections/websocket/full.md +83 -0
  121. package/docs/sections/websocket-rooms/full.md +6 -1
  122. package/package.json +16 -4
@@ -0,0 +1,6 @@
1
+ import type { StorageAdapter } from "../lib/storageAdapter";
2
+ export interface LocalStorageConfig {
3
+ directory: string;
4
+ baseUrl?: string;
5
+ }
6
+ export declare const localStorage: (config: LocalStorageConfig) => StorageAdapter;
@@ -0,0 +1,44 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const localStorage = (config) => ({
4
+ async put(key, data, _meta) {
5
+ const filePath = join(config.directory, key);
6
+ // Ensure parent directory exists
7
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
8
+ if (dir) {
9
+ const { mkdir } = await import("node:fs/promises");
10
+ await mkdir(dir, { recursive: true });
11
+ }
12
+ if (data instanceof Blob) {
13
+ await Bun.write(filePath, data);
14
+ }
15
+ else if (data instanceof ReadableStream) {
16
+ const response = new Response(data);
17
+ const blob = await response.blob();
18
+ await Bun.write(filePath, blob);
19
+ }
20
+ else {
21
+ await Bun.write(filePath, data);
22
+ }
23
+ const url = config.baseUrl ? `${config.baseUrl.replace(/\/$/, "")}/${key}` : undefined;
24
+ return { ...(url !== undefined ? { url } : {}) };
25
+ },
26
+ async get(key) {
27
+ const filePath = join(config.directory, key);
28
+ const file = Bun.file(filePath);
29
+ const exists = await file.exists();
30
+ if (!exists)
31
+ return null;
32
+ const stream = file.stream();
33
+ return { stream, size: file.size };
34
+ },
35
+ async delete(key) {
36
+ const filePath = join(config.directory, key);
37
+ try {
38
+ await unlink(filePath);
39
+ }
40
+ catch {
41
+ // File doesn't exist — ignore
42
+ }
43
+ },
44
+ });
@@ -10,6 +10,8 @@ export declare const memoryGetUserSessions: (userId: string) => SessionInfo[];
10
10
  export declare const memoryGetActiveSessionCount: (userId: string) => number;
11
11
  export declare const memoryEvictOldestSession: (userId: string) => void;
12
12
  export declare const memoryUpdateSessionLastActive: (sessionId: string) => void;
13
+ export declare const memoryGetSessionFingerprint: (sessionId: string) => string | null;
14
+ export declare const memorySetSessionFingerprint: (sessionId: string, fingerprint: string) => void;
13
15
  export declare const memorySetRefreshToken: (sessionId: string, refreshToken: string) => void;
14
16
  import type { RefreshResult } from "../lib/session";
15
17
  export declare const memoryGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
@@ -37,3 +39,8 @@ export declare const memoryConsumeResetToken: (hash: string) => {
37
39
  import type { OAuthCodePayload } from "../lib/oauthCode";
38
40
  export declare const memoryStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
39
41
  export declare const memoryConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;
42
+ export declare const memoryCreateDeletionCancelToken: (token: string, userId: string, jobId: string, ttlSeconds: number) => void;
43
+ export declare const memoryConsumeDeletionCancelToken: (hash: string) => {
44
+ userId: string;
45
+ jobId: string;
46
+ } | null;
@@ -2,6 +2,11 @@ import { HttpError } from "../lib/HttpError";
2
2
  import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
3
3
  import { clearMemoryRateLimitStore } from "../lib/authRateLimit";
4
4
  import { clearMemoryMfaChallenges } from "../lib/mfaChallenge";
5
+ import { clearAuditLogMemoryStore } from "../lib/auditLog";
6
+ import { clearPresenceStore } from "../lib/wsPresence";
7
+ import { clearWsMessageMemoryStore } from "../lib/wsMessages";
8
+ import { clearHeartbeatState } from "../lib/wsHeartbeat";
9
+ import { clearMemoryUploadStore } from "./memoryStorage";
5
10
  const _users = new Map();
6
11
  const _byEmail = new Map();
7
12
  const _sessions = new Map(); // sessionId → session
@@ -11,8 +16,11 @@ const _oauthStates = new Map();
11
16
  const _cache = new Map();
12
17
  const _verificationTokens = new Map();
13
18
  const _resetTokens = new Map();
19
+ const _cancelTokens = new Map();
14
20
  const _oauthCodes = new Map();
15
21
  const _tenantRoles = new Map(); // "userId:tenantId" → roles
22
+ const _groups = new Map(); // groupId → GroupRecord
23
+ const _groupMemberships = new Map();
16
24
  /** Reset all in-memory state. Useful for test isolation. */
17
25
  export const clearMemoryStore = () => {
18
26
  _users.clear();
@@ -21,13 +29,21 @@ export const clearMemoryStore = () => {
21
29
  _userSessionIds.clear();
22
30
  _refreshTokenIndex.clear();
23
31
  _tenantRoles.clear();
32
+ _groups.clear();
33
+ _groupMemberships.clear();
24
34
  _oauthStates.clear();
25
35
  _oauthCodes.clear();
26
36
  _cache.clear();
27
37
  _verificationTokens.clear();
28
38
  _resetTokens.clear();
39
+ _cancelTokens.clear();
29
40
  clearMemoryRateLimitStore();
30
41
  clearMemoryMfaChallenges();
42
+ clearAuditLogMemoryStore();
43
+ clearPresenceStore();
44
+ clearWsMessageMemoryStore();
45
+ clearHeartbeatState();
46
+ clearMemoryUploadStore();
31
47
  };
32
48
  // ---------------------------------------------------------------------------
33
49
  // Auth adapter
@@ -242,6 +258,106 @@ export const memoryAuthAdapter = {
242
258
  _tenantRoles.set(key, current.filter((r) => r !== role));
243
259
  }
244
260
  },
261
+ // ---------------------------------------------------------------------------
262
+ // Groups
263
+ // ---------------------------------------------------------------------------
264
+ async createGroup(group) {
265
+ // Enforce name uniqueness within scope (null = app-wide, string = tenant-scoped)
266
+ for (const g of _groups.values()) {
267
+ if (g.name === group.name && g.tenantId === group.tenantId) {
268
+ throw new HttpError(409, "A group with this name already exists in this scope");
269
+ }
270
+ }
271
+ const id = crypto.randomUUID();
272
+ const now = Date.now();
273
+ _groups.set(id, { ...group, id, createdAt: now, updatedAt: now });
274
+ return { id };
275
+ },
276
+ async deleteGroup(groupId) {
277
+ _groups.delete(groupId);
278
+ // Cascade: remove all memberships for this group
279
+ for (const [userId, memberships] of _groupMemberships) {
280
+ const filtered = memberships.filter((m) => m.groupId !== groupId);
281
+ if (filtered.length !== memberships.length) {
282
+ _groupMemberships.set(userId, filtered);
283
+ }
284
+ }
285
+ },
286
+ async getGroup(groupId) {
287
+ return _groups.get(groupId) ?? null;
288
+ },
289
+ async listGroups(tenantId, opts) {
290
+ const limit = Math.min(opts?.limit ?? 50, 200);
291
+ const offset = opts?.offset ?? 0;
292
+ const all = [..._groups.values()].filter((g) => g.tenantId === tenantId);
293
+ return { items: all.slice(offset, offset + limit), total: all.length, limit, offset };
294
+ },
295
+ async updateGroup(groupId, updates) {
296
+ const group = _groups.get(groupId);
297
+ if (!group)
298
+ return;
299
+ const now = Date.now();
300
+ _groups.set(groupId, { ...group, ...updates, id: group.id, tenantId: group.tenantId, createdAt: group.createdAt, updatedAt: now });
301
+ },
302
+ async addGroupMember(groupId, userId, roles = []) {
303
+ const group = _groups.get(groupId);
304
+ if (!group)
305
+ throw new HttpError(404, "Group not found");
306
+ const existing = _groupMemberships.get(userId) ?? [];
307
+ if (existing.some((m) => m.groupId === groupId)) {
308
+ throw new HttpError(409, "User is already a member of this group");
309
+ }
310
+ _groupMemberships.set(userId, [...existing, {
311
+ groupId, roles: [...roles], tenantId: group.tenantId, createdAt: Date.now(),
312
+ }]);
313
+ },
314
+ async updateGroupMembership(groupId, userId, roles) {
315
+ const memberships = _groupMemberships.get(userId);
316
+ if (!memberships)
317
+ return;
318
+ const idx = memberships.findIndex((m) => m.groupId === groupId);
319
+ if (idx === -1)
320
+ return;
321
+ memberships[idx] = { ...memberships[idx], roles: [...roles] };
322
+ },
323
+ async removeGroupMember(groupId, userId) {
324
+ const memberships = _groupMemberships.get(userId);
325
+ if (!memberships)
326
+ return;
327
+ _groupMemberships.set(userId, memberships.filter((m) => m.groupId !== groupId));
328
+ },
329
+ async getGroupMembers(groupId, opts) {
330
+ const limit = Math.min(opts?.limit ?? 50, 200);
331
+ const offset = opts?.offset ?? 0;
332
+ const all = [];
333
+ for (const [userId, memberships] of _groupMemberships) {
334
+ const m = memberships.find((m) => m.groupId === groupId);
335
+ if (m)
336
+ all.push({ userId, roles: [...m.roles] });
337
+ }
338
+ return { items: all.slice(offset, offset + limit), total: all.length, limit, offset };
339
+ },
340
+ async getUserGroups(userId, tenantId) {
341
+ const memberships = (_groupMemberships.get(userId) ?? []).filter((m) => m.tenantId === tenantId);
342
+ const result = [];
343
+ for (const m of memberships) {
344
+ const group = _groups.get(m.groupId);
345
+ if (group)
346
+ result.push({ group: { ...group }, membershipRoles: [...m.roles] });
347
+ }
348
+ return result;
349
+ },
350
+ async getEffectiveRoles(userId, tenantId) {
351
+ const direct = tenantId
352
+ ? (_tenantRoles.get(`${userId}:${tenantId}`) ?? [])
353
+ : (_users.get(userId)?.roles ?? []);
354
+ const memberships = (_groupMemberships.get(userId) ?? []).filter((m) => m.tenantId === tenantId);
355
+ const groupRoles = memberships.flatMap((m) => [
356
+ ...(_groups.get(m.groupId)?.roles ?? []),
357
+ ...m.roles,
358
+ ]);
359
+ return [...new Set([...direct, ...groupRoles])];
360
+ },
245
361
  };
246
362
  // ---------------------------------------------------------------------------
247
363
  // Session helpers (used by src/lib/session.ts)
@@ -349,6 +465,14 @@ export const memoryUpdateSessionLastActive = (sessionId) => {
349
465
  if (entry)
350
466
  entry.lastActiveAt = Date.now();
351
467
  };
468
+ export const memoryGetSessionFingerprint = (sessionId) => {
469
+ return _sessions.get(sessionId)?.fingerprint ?? null;
470
+ };
471
+ export const memorySetSessionFingerprint = (sessionId, fingerprint) => {
472
+ const entry = _sessions.get(sessionId);
473
+ if (entry)
474
+ entry.fingerprint = fingerprint;
475
+ };
352
476
  export const memorySetRefreshToken = (sessionId, refreshToken) => {
353
477
  const entry = _sessions.get(sessionId);
354
478
  if (!entry)
@@ -488,3 +612,23 @@ export const memoryConsumeOAuthCode = (hash) => {
488
612
  _oauthCodes.delete(hash);
489
613
  return { token: entry.token, userId: entry.userId, email: entry.email, refreshToken: entry.refreshToken };
490
614
  };
615
+ // ---------------------------------------------------------------------------
616
+ // Account deletion cancel token helpers (used by src/lib/deletionCancelToken.ts)
617
+ // ---------------------------------------------------------------------------
618
+ export const memoryCreateDeletionCancelToken = (token, userId, jobId, ttlSeconds) => {
619
+ const now = Date.now();
620
+ for (const [k, v] of _cancelTokens) {
621
+ if (v.expiresAt <= now)
622
+ _cancelTokens.delete(k);
623
+ }
624
+ _cancelTokens.set(token, { userId, jobId, expiresAt: now + ttlSeconds * 1000 });
625
+ };
626
+ export const memoryConsumeDeletionCancelToken = (hash) => {
627
+ const entry = _cancelTokens.get(hash);
628
+ if (!entry || entry.expiresAt <= Date.now()) {
629
+ _cancelTokens.delete(hash);
630
+ return null;
631
+ }
632
+ _cancelTokens.delete(hash);
633
+ return { userId: entry.userId, jobId: entry.jobId };
634
+ };
@@ -0,0 +1,3 @@
1
+ import type { StorageAdapter } from "../lib/storageAdapter";
2
+ export declare const clearMemoryUploadStore: () => void;
3
+ export declare const memoryStorage: () => StorageAdapter;
@@ -0,0 +1,44 @@
1
+ const _store = new Map();
2
+ export const clearMemoryUploadStore = () => {
3
+ _store.clear();
4
+ };
5
+ export const memoryStorage = () => ({
6
+ async put(key, data, meta) {
7
+ let buf;
8
+ if (data instanceof Blob) {
9
+ buf = Buffer.from(await data.arrayBuffer());
10
+ }
11
+ else if (data instanceof ReadableStream) {
12
+ const chunks = [];
13
+ const reader = data.getReader();
14
+ while (true) {
15
+ const { done, value } = await reader.read();
16
+ if (done)
17
+ break;
18
+ if (value)
19
+ chunks.push(value);
20
+ }
21
+ buf = Buffer.concat(chunks);
22
+ }
23
+ else {
24
+ buf = data;
25
+ }
26
+ _store.set(key, { data: buf, mimeType: meta.mimeType, size: meta.size });
27
+ return {};
28
+ },
29
+ async get(key) {
30
+ const entry = _store.get(key);
31
+ if (!entry)
32
+ return null;
33
+ const stream = new ReadableStream({
34
+ start(controller) {
35
+ controller.enqueue(entry.data);
36
+ controller.close();
37
+ },
38
+ });
39
+ return { stream, mimeType: entry.mimeType, size: entry.size };
40
+ },
41
+ async delete(key) {
42
+ _store.delete(key);
43
+ },
44
+ });
@@ -1,5 +1,7 @@
1
1
  import { AuthUser } from "../models/AuthUser";
2
2
  import { TenantRole } from "../models/TenantRole";
3
+ import { Group } from "../models/Group";
4
+ import { GroupMembership } from "../models/GroupMembership";
3
5
  import { HttpError } from "../lib/HttpError";
4
6
  export const mongoAuthAdapter = {
5
7
  async findByEmail(email) {
@@ -184,4 +186,122 @@ export const mongoAuthAdapter = {
184
186
  async removeTenantRole(userId, tenantId, role) {
185
187
  await TenantRole.findOneAndUpdate({ userId, tenantId }, { $pull: { roles: role } });
186
188
  },
189
+ // ---------------------------------------------------------------------------
190
+ // Groups
191
+ // ---------------------------------------------------------------------------
192
+ async createGroup(group) {
193
+ try {
194
+ const doc = await Group.create(group);
195
+ return { id: String(doc._id) };
196
+ }
197
+ catch (err) {
198
+ if (err?.code === 11000)
199
+ throw new HttpError(409, "A group with this name already exists in this scope");
200
+ throw err;
201
+ }
202
+ },
203
+ async deleteGroup(groupId) {
204
+ await Group.findByIdAndDelete(groupId);
205
+ // Explicit cascade (Mongoose middleware hooks are intentionally avoided for visibility)
206
+ await GroupMembership.deleteMany({ groupId });
207
+ },
208
+ async getGroup(groupId) {
209
+ const doc = await Group.findById(groupId).lean();
210
+ if (!doc)
211
+ return null;
212
+ return mongoGroupToRecord(doc);
213
+ },
214
+ async listGroups(tenantId, opts) {
215
+ const limit = Math.min(opts?.limit ?? 50, 200);
216
+ const offset = opts?.offset ?? 0;
217
+ const filter = { tenantId: tenantId ?? null };
218
+ const [docs, total] = await Promise.all([
219
+ Group.find(filter).skip(offset).limit(limit).lean(),
220
+ Group.countDocuments(filter),
221
+ ]);
222
+ return { items: docs.map(mongoGroupToRecord), total, limit, offset };
223
+ },
224
+ async updateGroup(groupId, updates) {
225
+ await Group.findByIdAndUpdate(groupId, { $set: updates });
226
+ },
227
+ async addGroupMember(groupId, userId, roles = []) {
228
+ // Look up group to get tenantId for denormalization
229
+ const group = await Group.findById(groupId, "tenantId").lean();
230
+ if (!group)
231
+ throw new HttpError(404, "Group not found");
232
+ try {
233
+ await GroupMembership.create({ groupId, userId, roles, tenantId: group.tenantId ?? null });
234
+ }
235
+ catch (err) {
236
+ if (err?.code === 11000)
237
+ throw new HttpError(409, "User is already a member of this group");
238
+ throw err;
239
+ }
240
+ },
241
+ async updateGroupMembership(groupId, userId, roles) {
242
+ await GroupMembership.findOneAndUpdate({ groupId, userId }, { $set: { roles } });
243
+ },
244
+ async removeGroupMember(groupId, userId) {
245
+ await GroupMembership.deleteOne({ groupId, userId });
246
+ },
247
+ async getGroupMembers(groupId, opts) {
248
+ const limit = Math.min(opts?.limit ?? 50, 200);
249
+ const offset = opts?.offset ?? 0;
250
+ const [docs, total] = await Promise.all([
251
+ GroupMembership.find({ groupId }, "userId roles").skip(offset).limit(limit).lean(),
252
+ GroupMembership.countDocuments({ groupId }),
253
+ ]);
254
+ return {
255
+ items: docs.map((d) => ({ userId: d.userId, roles: d.roles ?? [] })),
256
+ total, limit, offset,
257
+ };
258
+ },
259
+ async getUserGroups(userId, tenantId) {
260
+ const memberships = await GroupMembership.find({ userId, tenantId: tenantId ?? null }, "groupId roles").lean();
261
+ if (memberships.length === 0)
262
+ return [];
263
+ const groupIds = memberships.map((m) => m.groupId);
264
+ const groups = await Group.find({ _id: { $in: groupIds } }).lean();
265
+ const groupMap = new Map(groups.map((g) => [String(g._id), g]));
266
+ return memberships.map((m) => ({
267
+ group: mongoGroupToRecord(groupMap.get(m.groupId)),
268
+ membershipRoles: m.roles ?? [],
269
+ })).filter((r) => r.group);
270
+ },
271
+ async getEffectiveRoles(userId, tenantId) {
272
+ // Direct roles
273
+ let direct = [];
274
+ if (tenantId) {
275
+ const doc = await TenantRole.findOne({ userId, tenantId }, "roles").lean();
276
+ direct = doc?.roles ?? [];
277
+ }
278
+ else {
279
+ const user = await AuthUser.findById(userId, "roles").lean();
280
+ direct = user?.roles ?? [];
281
+ }
282
+ // Group roles via memberships
283
+ const memberships = await GroupMembership.find({ userId, tenantId: tenantId ?? null }, "groupId roles").lean();
284
+ if (memberships.length === 0)
285
+ return [...new Set(direct)];
286
+ const groupIds = memberships.map((m) => m.groupId);
287
+ const groups = await Group.find({ _id: { $in: groupIds } }, "roles").lean();
288
+ const groupMap = new Map(groups.map((g) => [String(g._id), g.roles ?? []]));
289
+ const groupRoles = memberships.flatMap((m) => [
290
+ ...(groupMap.get(m.groupId) ?? []),
291
+ ...(m.roles ?? []),
292
+ ]);
293
+ return [...new Set([...direct, ...groupRoles])];
294
+ },
187
295
  };
296
+ function mongoGroupToRecord(doc) {
297
+ return {
298
+ id: String(doc._id),
299
+ name: doc.name,
300
+ displayName: doc.displayName,
301
+ description: doc.description,
302
+ roles: doc.roles ?? [],
303
+ tenantId: doc.tenantId ?? null,
304
+ createdAt: doc.createdAt instanceof Date ? doc.createdAt.getTime() : doc.createdAt,
305
+ updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt.getTime() : doc.updatedAt,
306
+ };
307
+ }
@@ -0,0 +1,14 @@
1
+ import type { StorageAdapter } from "../lib/storageAdapter";
2
+ export interface S3StorageConfig {
3
+ bucket: string;
4
+ region?: string;
5
+ endpoint?: string;
6
+ credentials?: {
7
+ accessKeyId: string;
8
+ secretAccessKey: string;
9
+ };
10
+ publicUrl?: string;
11
+ forcePathStyle?: boolean;
12
+ streaming?: boolean;
13
+ }
14
+ export declare const s3Storage: (config: S3StorageConfig) => StorageAdapter;
@@ -0,0 +1,126 @@
1
+ function requireS3Client() {
2
+ try {
3
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4
+ return require("@aws-sdk/client-s3");
5
+ }
6
+ catch {
7
+ throw new Error("@aws-sdk/client-s3 is not installed. Run: bun add @aws-sdk/client-s3");
8
+ }
9
+ }
10
+ function requirePresigner() {
11
+ try {
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ return require("@aws-sdk/s3-request-presigner");
14
+ }
15
+ catch {
16
+ throw new Error("@aws-sdk/s3-request-presigner is not installed. Run: bun add @aws-sdk/s3-request-presigner");
17
+ }
18
+ }
19
+ function requireLibStorage() {
20
+ try {
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ return require("@aws-sdk/lib-storage");
23
+ }
24
+ catch {
25
+ throw new Error("@aws-sdk/lib-storage is not installed. Run: bun add @aws-sdk/lib-storage");
26
+ }
27
+ }
28
+ export const s3Storage = (config) => {
29
+ let _client = null;
30
+ const getClient = () => {
31
+ if (!_client) {
32
+ const { S3Client } = requireS3Client();
33
+ _client = new S3Client({
34
+ region: config.region ?? "us-east-1",
35
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
36
+ ...(config.credentials ? { credentials: config.credentials } : {}),
37
+ ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}),
38
+ });
39
+ }
40
+ return _client;
41
+ };
42
+ return {
43
+ async put(key, data, meta) {
44
+ const bucket = meta.bucket ?? config.bucket;
45
+ const client = getClient();
46
+ if (config.streaming && data instanceof ReadableStream) {
47
+ const { Upload } = requireLibStorage();
48
+ const upload = new Upload({
49
+ client,
50
+ params: {
51
+ Bucket: bucket,
52
+ Key: key,
53
+ Body: data,
54
+ ContentType: meta.mimeType,
55
+ },
56
+ });
57
+ await upload.done();
58
+ }
59
+ else {
60
+ const { PutObjectCommand } = requireS3Client();
61
+ let body;
62
+ if (data instanceof ReadableStream) {
63
+ const response = new Response(data);
64
+ body = Buffer.from(await response.arrayBuffer());
65
+ }
66
+ else if (data instanceof Blob) {
67
+ body = Buffer.from(await data.arrayBuffer());
68
+ }
69
+ else {
70
+ body = data;
71
+ }
72
+ await client.send(new PutObjectCommand({
73
+ Bucket: bucket,
74
+ Key: key,
75
+ Body: body,
76
+ ContentType: meta.mimeType,
77
+ ContentLength: meta.size,
78
+ }));
79
+ }
80
+ const url = config.publicUrl
81
+ ? `${config.publicUrl.replace(/\/$/, "")}/${key}`
82
+ : undefined;
83
+ return { ...(url !== undefined ? { url } : {}) };
84
+ },
85
+ async get(key) {
86
+ const { GetObjectCommand } = requireS3Client();
87
+ const client = getClient();
88
+ const bucket = config.bucket;
89
+ try {
90
+ const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
91
+ return {
92
+ stream: result.Body,
93
+ mimeType: result.ContentType,
94
+ size: result.ContentLength,
95
+ };
96
+ }
97
+ catch (err) {
98
+ if (err?.name === "NoSuchKey" || err?.$metadata?.httpStatusCode === 404)
99
+ return null;
100
+ throw err;
101
+ }
102
+ },
103
+ async delete(key) {
104
+ const { DeleteObjectCommand } = requireS3Client();
105
+ const client = getClient();
106
+ await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
107
+ },
108
+ async presignPut(key, opts) {
109
+ const { PutObjectCommand } = requireS3Client();
110
+ const { getSignedUrl } = requirePresigner();
111
+ const client = getClient();
112
+ const params = {
113
+ Bucket: config.bucket,
114
+ Key: key,
115
+ ...(opts.mimeType ? { ContentType: opts.mimeType } : {}),
116
+ };
117
+ return getSignedUrl(client, new PutObjectCommand(params), { expiresIn: opts.expirySeconds });
118
+ },
119
+ async presignGet(key, opts) {
120
+ const { GetObjectCommand } = requireS3Client();
121
+ const { getSignedUrl } = requirePresigner();
122
+ const client = getClient();
123
+ return getSignedUrl(client, new GetObjectCommand({ Bucket: config.bucket, Key: key }), { expiresIn: opts.expirySeconds });
124
+ },
125
+ };
126
+ };
@@ -15,6 +15,8 @@ import type { RefreshResult } from "../lib/session";
15
15
  export declare const sqliteSetRefreshToken: (sessionId: string, refreshToken: string) => void;
16
16
  export declare const sqliteGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
17
17
  export declare const sqliteRotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => void;
18
+ export declare const sqliteGetSessionFingerprint: (sessionId: string) => string | null;
19
+ export declare const sqliteSetSessionFingerprint: (sessionId: string, fingerprint: string) => void;
18
20
  export declare const sqliteStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
19
21
  export declare const sqliteConsumeOAuthState: (state: string) => {
20
22
  codeVerifier?: string;
@@ -36,6 +38,11 @@ export declare const sqliteConsumeResetToken: (hash: string) => {
36
38
  userId: string;
37
39
  email: string;
38
40
  } | null;
41
+ export declare const sqliteCreateDeletionCancelToken: (token: string, userId: string, jobId: string, ttlSeconds: number) => void;
42
+ export declare const sqliteConsumeDeletionCancelToken: (hash: string) => {
43
+ userId: string;
44
+ jobId: string;
45
+ } | null;
39
46
  import type { OAuthCodePayload } from "../lib/oauthCode";
40
47
  export declare const sqliteStoreOAuthCode: (hash: string, payload: OAuthCodePayload, ttlSeconds: number) => void;
41
48
  export declare const sqliteConsumeOAuthCode: (hash: string) => OAuthCodePayload | null;