@lastshotlabs/bunshot 0.0.21 → 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 +247 -46
  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,30 @@
1
+ import type { Document, Model } from "mongoose";
2
+ interface IAuditLog {
3
+ /** UUID assigned by the caller — stable cross-store identifier. */
4
+ id: string;
5
+ userId: string | null;
6
+ sessionId: string | null;
7
+ tenantId: string | null;
8
+ method: string;
9
+ path: string;
10
+ status: number;
11
+ ip: string | null;
12
+ userAgent: string | null;
13
+ action?: string;
14
+ resource?: string;
15
+ resourceId?: string;
16
+ meta?: Record<string, unknown>;
17
+ createdAt: Date;
18
+ /**
19
+ * Optional TTL field. MongoDB will automatically delete the document
20
+ * once this date is in the past (via `expireAfterSeconds: 0` index).
21
+ */
22
+ expiresAt?: Date;
23
+ }
24
+ type AuditLogDocument = IAuditLog & Document;
25
+ export declare const AuditLog: Model<AuditLogDocument, {}, {}, {}, Document<unknown, {}, AuditLogDocument, {}, import("mongoose").DefaultSchemaOptions> & IAuditLog & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
26
+ _id: import("mongoose").Types.ObjectId;
27
+ }> & {
28
+ __v: number;
29
+ }, any, AuditLogDocument>;
30
+ export {};
@@ -0,0 +1,39 @@
1
+ import { authConnection, mongoose } from "../lib/mongo";
2
+ let _AuditLog = null;
3
+ function getAuditLogModel() {
4
+ if (!_AuditLog) {
5
+ const { Schema } = mongoose;
6
+ const schema = new Schema({
7
+ id: { type: String, required: true, unique: true },
8
+ userId: { type: String, default: null },
9
+ sessionId: { type: String, default: null },
10
+ tenantId: { type: String, default: null },
11
+ method: { type: String, required: true },
12
+ path: { type: String, required: true },
13
+ status: { type: Number, required: true },
14
+ ip: { type: String, default: null },
15
+ userAgent: { type: String, default: null },
16
+ action: { type: String },
17
+ resource: { type: String },
18
+ resourceId: { type: String },
19
+ meta: { type: Schema.Types.Mixed },
20
+ expiresAt: { type: Date, index: { expireAfterSeconds: 0 } },
21
+ }, {
22
+ collection: "audit_logs",
23
+ // Mongoose manages createdAt; no updatedAt needed for immutable log entries.
24
+ timestamps: { createdAt: "createdAt", updatedAt: false },
25
+ });
26
+ schema.index({ userId: 1, createdAt: 1 });
27
+ schema.index({ tenantId: 1, createdAt: 1 });
28
+ schema.index({ path: 1 });
29
+ _AuditLog = authConnection.model("AuditLog", schema);
30
+ }
31
+ return _AuditLog;
32
+ }
33
+ export const AuditLog = new Proxy({}, {
34
+ get(_, prop) {
35
+ const model = getAuditLogModel();
36
+ const val = model[prop];
37
+ return typeof val === "function" ? val.bind(model) : val;
38
+ },
39
+ });
@@ -0,0 +1,21 @@
1
+ import type { Document, Model } from "mongoose";
2
+ interface IGroup {
3
+ name: string;
4
+ displayName?: string;
5
+ description?: string;
6
+ roles: string[];
7
+ /**
8
+ * null = app-wide group, string = tenant-scoped group.
9
+ * Immutable after creation — adapters must reject updates that include tenantId.
10
+ */
11
+ tenantId: string | null;
12
+ }
13
+ type GroupDocument = IGroup & Document;
14
+ export declare const Group: Model<GroupDocument, {}, {}, {}, Document<unknown, {}, GroupDocument, {}, import("mongoose").DefaultSchemaOptions> & IGroup & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
15
+ _id: import("mongoose").Types.ObjectId;
16
+ }> & {
17
+ __v: number;
18
+ } & {
19
+ id: string;
20
+ }, any, GroupDocument>;
21
+ export {};
@@ -0,0 +1,28 @@
1
+ import { authConnection, mongoose } from "../lib/mongo";
2
+ let _Group = null;
3
+ function getGroup() {
4
+ if (!_Group) {
5
+ const { Schema } = mongoose;
6
+ const schema = new Schema({
7
+ name: { type: String, required: true },
8
+ displayName: { type: String },
9
+ description: { type: String },
10
+ roles: [{ type: String }],
11
+ tenantId: { type: String, default: null },
12
+ }, { timestamps: true });
13
+ // Name is unique within scope (app-wide or per-tenant).
14
+ // MongoDB treats null as a value, so this compound index correctly enforces uniqueness
15
+ // for app-wide groups (both have tenantId: null) and per-tenant groups separately.
16
+ schema.index({ name: 1, tenantId: 1 }, { unique: true });
17
+ schema.index({ tenantId: 1 });
18
+ _Group = authConnection.model("Group", schema);
19
+ }
20
+ return _Group;
21
+ }
22
+ export const Group = new Proxy({}, {
23
+ get(_, prop) {
24
+ const model = getGroup();
25
+ const val = model[prop];
26
+ return typeof val === "function" ? val.bind(model) : val;
27
+ },
28
+ });
@@ -0,0 +1,21 @@
1
+ import type { Document, Model } from "mongoose";
2
+ interface IGroupMembership {
3
+ userId: string;
4
+ groupId: string;
5
+ /** Per-member extra roles on top of the group's baseline roles. */
6
+ roles: string[];
7
+ /**
8
+ * Denormalized from the group at insert time for efficient tenant-scoped queries.
9
+ * Immutable: group.tenantId cannot change after creation, so this is always consistent.
10
+ */
11
+ tenantId: string | null;
12
+ }
13
+ type GroupMembershipDocument = IGroupMembership & Document;
14
+ export declare const GroupMembership: Model<GroupMembershipDocument, {}, {}, {}, Document<unknown, {}, GroupMembershipDocument, {}, import("mongoose").DefaultSchemaOptions> & IGroupMembership & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
15
+ _id: import("mongoose").Types.ObjectId;
16
+ }> & {
17
+ __v: number;
18
+ } & {
19
+ id: string;
20
+ }, any, GroupMembershipDocument>;
21
+ export {};
@@ -0,0 +1,25 @@
1
+ import { authConnection, mongoose } from "../lib/mongo";
2
+ let _GroupMembership = null;
3
+ function getGroupMembership() {
4
+ if (!_GroupMembership) {
5
+ const { Schema } = mongoose;
6
+ const schema = new Schema({
7
+ userId: { type: String, required: true },
8
+ groupId: { type: String, required: true },
9
+ roles: [{ type: String }],
10
+ tenantId: { type: String, default: null },
11
+ }, { timestamps: { createdAt: true, updatedAt: false } });
12
+ schema.index({ userId: 1, groupId: 1 }, { unique: true });
13
+ schema.index({ groupId: 1 });
14
+ schema.index({ userId: 1, tenantId: 1 });
15
+ _GroupMembership = authConnection.model("GroupMembership", schema);
16
+ }
17
+ return _GroupMembership;
18
+ }
19
+ export const GroupMembership = new Proxy({}, {
20
+ get(_, prop) {
21
+ const model = getGroupMembership();
22
+ const val = model[prop];
23
+ return typeof val === "function" ? val.bind(model) : val;
24
+ },
25
+ });
@@ -10,7 +10,8 @@ import { getAuthAdapter } from "../lib/authAdapter";
10
10
  import { createRouter } from "../lib/context";
11
11
  import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
12
12
  import { createResetToken, consumeResetToken } from "../lib/resetPassword";
13
- import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
13
+ import { createDeletionCancelToken, consumeDeletionCancelToken } from "../lib/deletionCancelToken";
14
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled, getAppName } from "../lib/appConfig";
14
15
  import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
15
16
  import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
16
17
  import { getClientIp } from "../lib/clientIp";
@@ -24,7 +25,8 @@ const TokenResponse = z.object({
24
25
  refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
25
26
  mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
26
27
  mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
27
- mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
28
+ mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp', 'webauthn')."),
29
+ webauthnOptions: z.record(z.string(), z.unknown()).optional().describe("WebAuthn assertion options (present when mfaMethods includes 'webauthn'). Pass directly to navigator.credentials.get()."),
28
30
  }).openapi("TokenResponse");
29
31
  const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
30
32
  const tags = ["Auth"];
@@ -212,6 +214,33 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
212
214
  if (accountDeletion?.onBeforeDelete) {
213
215
  await accountDeletion.onBeforeDelete(authUserId);
214
216
  }
217
+ // Queued deletion via BullMQ
218
+ if (accountDeletion?.queued) {
219
+ const { createQueue } = await import("../lib/queue");
220
+ const appName = getAppName();
221
+ const queue = createQueue(`${appName}:account-deletions`);
222
+ const delayMs = (accountDeletion.gracePeriod ?? 0) * 1000;
223
+ const job = await queue.add("delete-account", { userId: authUserId }, {
224
+ delay: delayMs,
225
+ attempts: 3,
226
+ backoff: { type: "exponential", delay: 1000 },
227
+ removeOnComplete: true,
228
+ removeOnFail: 100,
229
+ });
230
+ await queue.close();
231
+ // Revoke sessions immediately so the user is logged out
232
+ await deleteUserSessions(authUserId);
233
+ if (accountDeletion.gracePeriod && accountDeletion.onDeletionScheduled) {
234
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
235
+ const email = user?.email ?? "";
236
+ if (email) {
237
+ const cancelToken = await createDeletionCancelToken(authUserId, job.id, accountDeletion.gracePeriod);
238
+ await accountDeletion.onDeletionScheduled(authUserId, email, cancelToken);
239
+ }
240
+ }
241
+ deleteCookie(c, COOKIE_TOKEN, { path: "/" });
242
+ return c.json({ message: "Account deletion scheduled" }, 202);
243
+ }
215
244
  // Synchronous deletion (default)
216
245
  await deleteUserSessions(authUserId);
217
246
  await adapter.deleteUser(authUserId);
@@ -297,12 +326,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
297
326
  method: "post",
298
327
  path: "/auth/resend-verification",
299
328
  summary: "Resend verification email",
300
- description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
329
+ description: "Authenticates with credentials and sends a new verification email. Always returns 200 for valid credentials regardless of verification status, to prevent user enumeration. Rate-limited per identifier. Does not require a session.",
301
330
  tags,
302
331
  request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
303
332
  responses: {
304
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
305
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
333
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent, or account is already verified (indistinguishable by design)." },
334
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "No email address on file for this account." },
306
335
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
307
336
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
308
337
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
@@ -323,8 +352,10 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
323
352
  return c.json({ error: "Invalid credentials" }, 401);
324
353
  }
325
354
  const alreadyVerified = await adapter.getEmailVerified(user.id);
355
+ // Return 200 (not 400) to avoid revealing whether the account is verified —
356
+ // distinguishing verified vs unverified would let attackers confirm valid credentials.
326
357
  if (alreadyVerified)
327
- return c.json({ error: "Email already verified" }, 400);
358
+ return c.json({ message: "Verification email sent if not already verified" }, 200);
328
359
  const fullUser = await adapter.getUser(user.id);
329
360
  if (!fullUser?.email)
330
361
  return c.json({ error: "No email address on file" }, 400);
@@ -470,6 +501,53 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
470
501
  });
471
502
  }
472
503
  // ---------------------------------------------------------------------------
504
+ // Account deletion cancellation — only mounted when queued: true + gracePeriod > 0
505
+ // ---------------------------------------------------------------------------
506
+ if (accountDeletion?.queued && accountDeletion.gracePeriod) {
507
+ router.openapi(createRoute({
508
+ method: "post",
509
+ path: "/auth/cancel-deletion",
510
+ summary: "Cancel scheduled account deletion",
511
+ description: "Cancels a pending queued account deletion using the cancel token sent via the onDeletionScheduled callback. Must be called before the grace period expires.",
512
+ tags,
513
+ request: {
514
+ body: {
515
+ content: {
516
+ "application/json": {
517
+ schema: z.object({
518
+ token: z.string().describe("Cancel token received in the deletion scheduled notification."),
519
+ }),
520
+ },
521
+ },
522
+ description: "Cancel token.",
523
+ },
524
+ },
525
+ responses: {
526
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion cancelled." },
527
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired cancel token." },
528
+ },
529
+ }), async (c) => {
530
+ const { token } = c.req.valid("json");
531
+ const entry = await consumeDeletionCancelToken(token);
532
+ if (!entry)
533
+ return c.json({ error: "Invalid or expired cancel token" }, 400);
534
+ // Remove the pending BullMQ job
535
+ try {
536
+ const { createQueue } = await import("../lib/queue");
537
+ const appName = getAppName();
538
+ const queue = createQueue(`${appName}:account-deletions`);
539
+ const job = await queue.getJob(entry.jobId);
540
+ if (job)
541
+ await job.remove();
542
+ await queue.close();
543
+ }
544
+ catch {
545
+ // Job may have already executed — that's an error case but we still consumed the token
546
+ }
547
+ return c.json({ message: "Account deletion cancelled" }, 200);
548
+ });
549
+ }
550
+ // ---------------------------------------------------------------------------
473
551
  // Session management
474
552
  // ---------------------------------------------------------------------------
475
553
  const SessionInfoSchema = z.object({
@@ -0,0 +1,21 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "../lib/context";
3
+ export interface GroupsManagementConfig {
4
+ /**
5
+ * Role required to access all management routes.
6
+ * Applied via requireRole.global(adminRole). Default: "admin".
7
+ * Ignored if `middleware` is provided.
8
+ */
9
+ adminRole?: string;
10
+ /**
11
+ * Fully replaces the default auth middleware stack [userAuth, requireRole.global(adminRole)].
12
+ * Use only when a single role check is insufficient.
13
+ * When provided, `adminRole` is ignored.
14
+ */
15
+ middleware?: MiddlewareHandler<AppEnv>[];
16
+ }
17
+ export interface GroupsConfig {
18
+ /** Mount group management routes. Pass `true` to use all defaults. */
19
+ managementRoutes?: GroupsManagementConfig | true;
20
+ }
21
+ export declare const createGroupsRouter: (config: GroupsConfig) => import("@hono/zod-openapi").OpenAPIHono<AppEnv, {}, "/">;