@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
@@ -1,8 +1,8 @@
1
1
  import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
3
  import { getAppName, getPersistSessionMetadata, getIncludeInactiveSessions, getRotationGraceSeconds, getRefreshTokenExpiry } from "./appConfig";
4
- import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, sqliteSetRefreshToken, sqliteGetSessionByRefreshToken, sqliteRotateRefreshToken, } from "../adapters/sqliteAuth";
5
- import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, memorySetRefreshToken, memoryGetSessionByRefreshToken, memoryRotateRefreshToken, } from "../adapters/memoryAuth";
4
+ import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, sqliteSetRefreshToken, sqliteGetSessionByRefreshToken, sqliteRotateRefreshToken, sqliteGetSessionFingerprint, sqliteSetSessionFingerprint, } from "../adapters/sqliteAuth";
5
+ import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, memorySetRefreshToken, memoryGetSessionByRefreshToken, memoryRotateRefreshToken, memoryGetSessionFingerprint, memorySetSessionFingerprint, } from "../adapters/memoryAuth";
6
6
  function getSessionModel() {
7
7
  if (appConnection.models["Session"])
8
8
  return appConnection.models["Session"];
@@ -19,6 +19,7 @@ function getSessionModel() {
19
19
  refreshToken: { type: String, default: null },
20
20
  prevRefreshToken: { type: String, default: null },
21
21
  prevTokenExpiresAt: { type: Date, default: null },
22
+ fingerprint: { type: String, default: null },
22
23
  }, { collection: "sessions", timestamps: false });
23
24
  sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
24
25
  // Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
@@ -479,3 +480,56 @@ export const rotateRefreshToken = async (sessionId, newRefreshToken, newAccessTo
479
480
  }
480
481
  await mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
481
482
  };
483
+ // ---------------------------------------------------------------------------
484
+ // Session fingerprint API (session binding feature)
485
+ // ---------------------------------------------------------------------------
486
+ /** Read the stored fingerprint for a session. Returns null if not yet set. */
487
+ export const getSessionFingerprint = async (sessionId) => {
488
+ if (_store === "memory")
489
+ return memoryGetSessionFingerprint(sessionId);
490
+ if (_store === "sqlite")
491
+ return sqliteGetSessionFingerprint(sessionId);
492
+ if (_store === "redis") {
493
+ const redis = getRedis();
494
+ const raw = await redis.get(redisSessionKey(sessionId));
495
+ if (!raw)
496
+ return null;
497
+ const rec = JSON.parse(raw);
498
+ return rec.fingerprint ?? null;
499
+ }
500
+ // mongo
501
+ const doc = await getSessionModel().findOne({ sessionId }, "fingerprint").lean();
502
+ return doc?.fingerprint ?? null;
503
+ };
504
+ /** Store a fingerprint on an existing session. No-op if the session does not exist. */
505
+ export const setSessionFingerprint = async (sessionId, fingerprint) => {
506
+ if (_store === "memory") {
507
+ memorySetSessionFingerprint(sessionId, fingerprint);
508
+ return;
509
+ }
510
+ if (_store === "sqlite") {
511
+ sqliteSetSessionFingerprint(sessionId, fingerprint);
512
+ return;
513
+ }
514
+ if (_store === "redis") {
515
+ const redis = getRedis();
516
+ const raw = await redis.get(redisSessionKey(sessionId));
517
+ if (!raw)
518
+ return;
519
+ const rec = JSON.parse(raw);
520
+ rec.fingerprint = fingerprint;
521
+ if (getPersistSessionMetadata()) {
522
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
523
+ }
524
+ else {
525
+ const now = Date.now();
526
+ if (rec.expiresAt <= now)
527
+ return;
528
+ const ttlRemaining = Math.max(1, Math.ceil((rec.expiresAt - now) / 1000));
529
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
530
+ }
531
+ return;
532
+ }
533
+ // mongo
534
+ await getSessionModel().updateOne({ sessionId }, { $set: { fingerprint } });
535
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Sign `data` with the active key (first element of `secret`).
3
+ * Normalizes string | string[] so that an array is never passed directly to
4
+ * createHmac() — which would silently call .toString() and produce
5
+ * "[object Array]" as the key.
6
+ */
7
+ export declare function hmacSign(data: string, secret: string | string[]): string;
8
+ /**
9
+ * Verify `sig` against `data` using one of the provided keys.
10
+ * Keys are tried newest-first (index 0 is the active signing key).
11
+ *
12
+ * Key ordering convention: put the current (newest) key first; rotated keys
13
+ * after. The common case (valid current-key signature) succeeds on the first
14
+ * comparison; old rotated keys only matter for in-flight tokens.
15
+ *
16
+ * MUST use timingSafeEqual — never === — to prevent timing side-channel leaks.
17
+ * This is the most common HMAC implementation mistake.
18
+ */
19
+ export declare function hmacVerify(data: string, sig: string, secret: string | string[]): boolean;
20
+ /** Returns `"base64url(value).hmac"`. */
21
+ export declare function signCookieValue(value: string, secret: string | string[]): string;
22
+ /** Returns the original value or `null` if the signature is invalid. */
23
+ export declare function verifyCookieValue(signed: string, secret: string | string[]): string | null;
24
+ /** Returns `"base64url(payload).hmac"`. */
25
+ export declare function signCursor(payload: string, secret: string | string[]): string;
26
+ /** Returns the original payload or `null` if the signature is invalid. */
27
+ export declare function verifyCursor(cursor: string, secret: string | string[]): string | null;
28
+ /**
29
+ * Create a stateless HMAC-signed URL. The signature covers the HTTP method,
30
+ * storage key, and expiry timestamp so that:
31
+ * - Expired URLs are rejected (replay prevention)
32
+ * - URLs are method-bound (a GET URL can't be replayed as a PUT)
33
+ * - Tampering with the key or expiry invalidates the signature
34
+ *
35
+ * @param base Base URL string (e.g. "https://api.example.com/uploads/presign")
36
+ * @param key Storage object key
37
+ * @param opts Method, expiry in seconds from now, optional extra query params
38
+ * @param secret HMAC secret (supports key rotation via string[])
39
+ */
40
+ export declare function createPresignedUrl(base: string, key: string, opts: {
41
+ method: string;
42
+ expiry: number;
43
+ extra?: Record<string, string>;
44
+ }, secret: string | string[]): string;
45
+ /**
46
+ * Verify an HMAC-signed URL. Returns the key and any extra params, or null
47
+ * if the URL is expired, tampered, or method-mismatched.
48
+ */
49
+ export declare function verifyPresignedUrl(url: string, method: string, secret: string | string[]): {
50
+ key: string;
51
+ extra?: Record<string, string>;
52
+ } | null;
@@ -0,0 +1,180 @@
1
+ import { createHmac } from "crypto";
2
+ import { timingSafeEqual } from "./crypto";
3
+ // ---------------------------------------------------------------------------
4
+ // Core HMAC primitives
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Sign `data` with the active key (first element of `secret`).
8
+ * Normalizes string | string[] so that an array is never passed directly to
9
+ * createHmac() — which would silently call .toString() and produce
10
+ * "[object Array]" as the key.
11
+ */
12
+ export function hmacSign(data, secret) {
13
+ const key = Array.isArray(secret) ? secret[0] : secret;
14
+ return createHmac("sha256", key).update(data).digest("hex");
15
+ }
16
+ /**
17
+ * Verify `sig` against `data` using one of the provided keys.
18
+ * Keys are tried newest-first (index 0 is the active signing key).
19
+ *
20
+ * Key ordering convention: put the current (newest) key first; rotated keys
21
+ * after. The common case (valid current-key signature) succeeds on the first
22
+ * comparison; old rotated keys only matter for in-flight tokens.
23
+ *
24
+ * MUST use timingSafeEqual — never === — to prevent timing side-channel leaks.
25
+ * This is the most common HMAC implementation mistake.
26
+ */
27
+ export function hmacVerify(data, sig, secret) {
28
+ const keys = Array.isArray(secret) ? secret : [secret];
29
+ for (const key of keys) {
30
+ const expected = createHmac("sha256", key).update(data).digest("hex");
31
+ try {
32
+ if (timingSafeEqual(expected, sig))
33
+ return true;
34
+ }
35
+ catch {
36
+ // timingSafeEqual can throw on length mismatch with multi-byte chars
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Cookie signing
43
+ //
44
+ // Value is base64url-encoded before appending ".sig" to avoid delimiter
45
+ // collision — raw values may contain "." which would break naive
46
+ // split-on-last-dot parsing.
47
+ //
48
+ // Edge case: base64url("") === "" so the signed form for an empty value is
49
+ // ".sig". Split uses lastIndexOf("."), not indexOf("."), and dotIdx === 0
50
+ // is treated as a valid (empty) value, not a parse error.
51
+ // ---------------------------------------------------------------------------
52
+ function toBase64url(s) {
53
+ return Buffer.from(s).toString("base64url");
54
+ }
55
+ function fromBase64url(s) {
56
+ return Buffer.from(s, "base64url").toString("utf8");
57
+ }
58
+ /** Returns `"base64url(value).hmac"`. */
59
+ export function signCookieValue(value, secret) {
60
+ const encoded = toBase64url(value);
61
+ const sig = hmacSign(encoded, secret);
62
+ return `${encoded}.${sig}`;
63
+ }
64
+ /** Returns the original value or `null` if the signature is invalid. */
65
+ export function verifyCookieValue(signed, secret) {
66
+ const dotIdx = signed.lastIndexOf(".");
67
+ // dotIdx === 0 is valid: empty encoded value (signed form ".sig")
68
+ if (dotIdx < 0)
69
+ return null;
70
+ const encoded = signed.slice(0, dotIdx);
71
+ const sig = signed.slice(dotIdx + 1);
72
+ if (!hmacVerify(encoded, sig, secret))
73
+ return null;
74
+ try {
75
+ return fromBase64url(encoded);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Cursor signing (same structure as cookie signing)
83
+ // ---------------------------------------------------------------------------
84
+ /** Returns `"base64url(payload).hmac"`. */
85
+ export function signCursor(payload, secret) {
86
+ const encoded = toBase64url(payload);
87
+ const sig = hmacSign(encoded, secret);
88
+ return `${encoded}.${sig}`;
89
+ }
90
+ /** Returns the original payload or `null` if the signature is invalid. */
91
+ export function verifyCursor(cursor, secret) {
92
+ const dotIdx = cursor.lastIndexOf(".");
93
+ if (dotIdx < 0)
94
+ return null;
95
+ const encoded = cursor.slice(0, dotIdx);
96
+ const sig = cursor.slice(dotIdx + 1);
97
+ if (!hmacVerify(encoded, sig, secret))
98
+ return null;
99
+ try {
100
+ return fromBase64url(encoded);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Presigned URLs
108
+ //
109
+ // Signing data = method + "\n" + key + "\n" + exp
110
+ // Newline delimiter is safe: keys like "uploads/2024/photo.jpg" contain dots
111
+ // but cannot contain newlines; method and exp never contain newlines.
112
+ // Using "." would create ambiguity with keys containing dots.
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Create a stateless HMAC-signed URL. The signature covers the HTTP method,
116
+ * storage key, and expiry timestamp so that:
117
+ * - Expired URLs are rejected (replay prevention)
118
+ * - URLs are method-bound (a GET URL can't be replayed as a PUT)
119
+ * - Tampering with the key or expiry invalidates the signature
120
+ *
121
+ * @param base Base URL string (e.g. "https://api.example.com/uploads/presign")
122
+ * @param key Storage object key
123
+ * @param opts Method, expiry in seconds from now, optional extra query params
124
+ * @param secret HMAC secret (supports key rotation via string[])
125
+ */
126
+ export function createPresignedUrl(base, key, opts, secret) {
127
+ const exp = Math.floor(Date.now() / 1000) + opts.expiry;
128
+ const method = opts.method.toUpperCase();
129
+ const data = `${method}\n${key}\n${exp}`;
130
+ const sig = hmacSign(data, secret);
131
+ const url = new URL(base);
132
+ url.searchParams.set("key", key);
133
+ url.searchParams.set("exp", String(exp));
134
+ url.searchParams.set("method", method);
135
+ url.searchParams.set("sig", sig);
136
+ if (opts.extra) {
137
+ for (const [k, v] of Object.entries(opts.extra)) {
138
+ url.searchParams.set(k, v);
139
+ }
140
+ }
141
+ return url.toString();
142
+ }
143
+ /**
144
+ * Verify an HMAC-signed URL. Returns the key and any extra params, or null
145
+ * if the URL is expired, tampered, or method-mismatched.
146
+ */
147
+ export function verifyPresignedUrl(url, method, secret) {
148
+ let parsedUrl;
149
+ try {
150
+ parsedUrl = new URL(url);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ const key = parsedUrl.searchParams.get("key");
156
+ const exp = parsedUrl.searchParams.get("exp");
157
+ const sig = parsedUrl.searchParams.get("sig");
158
+ const urlMethod = parsedUrl.searchParams.get("method");
159
+ if (!key || !exp || !sig || !urlMethod)
160
+ return null;
161
+ // Method binding check
162
+ if (urlMethod !== method.toUpperCase())
163
+ return null;
164
+ // Expiry check
165
+ const expNum = parseInt(exp, 10);
166
+ if (isNaN(expNum) || expNum < Math.floor(Date.now() / 1000))
167
+ return null;
168
+ // Signature check
169
+ const data = `${urlMethod}\n${key}\n${exp}`;
170
+ if (!hmacVerify(data, sig, secret))
171
+ return null;
172
+ // Collect extra params (all except reserved ones)
173
+ const reserved = new Set(["key", "exp", "sig", "method"]);
174
+ const extra = {};
175
+ for (const [k, v] of parsedUrl.searchParams.entries()) {
176
+ if (!reserved.has(k))
177
+ extra[k] = v;
178
+ }
179
+ return Object.keys(extra).length > 0 ? { key, extra } : { key };
180
+ }
@@ -0,0 +1,30 @@
1
+ export interface StorageAdapter {
2
+ put(key: string, data: Blob | Buffer | ReadableStream, meta: {
3
+ mimeType: string;
4
+ size: number;
5
+ bucket?: string;
6
+ }): Promise<{
7
+ url?: string;
8
+ }>;
9
+ get(key: string): Promise<{
10
+ stream: ReadableStream;
11
+ mimeType?: string;
12
+ size?: number;
13
+ } | null>;
14
+ delete(key: string): Promise<void>;
15
+ presignPut?(key: string, opts: {
16
+ expirySeconds: number;
17
+ mimeType?: string;
18
+ maxSize?: number;
19
+ }): Promise<string>;
20
+ presignGet?(key: string, opts: {
21
+ expirySeconds: number;
22
+ }): Promise<string>;
23
+ }
24
+ export interface UploadResult {
25
+ key: string;
26
+ originalName: string;
27
+ mimeType: string;
28
+ size: number;
29
+ url?: string;
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Post-processes an OpenAPI 3.x spec object to remove `components/schemas` entries
3
+ * not directly or transitively referenced by any path operation.
4
+ *
5
+ * Prevents phantom types in generated TypeScript clients (openapi-typescript, orval)
6
+ * when multiple versioned specs share a single OpenAPI registry.
7
+ *
8
+ * @param spec - The OpenAPI spec document (from `app.getOpenAPIDocument()`).
9
+ * @returns A shallow-cloned spec with unreferenced schemas removed.
10
+ */
11
+ export declare function stripUnreferencedSchemas(spec: Record<string, any>): Record<string, any>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Post-processes an OpenAPI 3.x spec object to remove `components/schemas` entries
3
+ * not directly or transitively referenced by any path operation.
4
+ *
5
+ * Prevents phantom types in generated TypeScript clients (openapi-typescript, orval)
6
+ * when multiple versioned specs share a single OpenAPI registry.
7
+ *
8
+ * @param spec - The OpenAPI spec document (from `app.getOpenAPIDocument()`).
9
+ * @returns A shallow-cloned spec with unreferenced schemas removed.
10
+ */
11
+ export function stripUnreferencedSchemas(spec) {
12
+ const schemas = spec?.components?.schemas;
13
+ if (!schemas || typeof schemas !== "object")
14
+ return spec;
15
+ // Collect all $ref strings from an arbitrary JSON node
16
+ function collectRefs(node, refs) {
17
+ if (!node || typeof node !== "object")
18
+ return;
19
+ if (Array.isArray(node)) {
20
+ for (const item of node)
21
+ collectRefs(item, refs);
22
+ return;
23
+ }
24
+ for (const [key, val] of Object.entries(node)) {
25
+ if (key === "$ref" && typeof val === "string") {
26
+ refs.add(val);
27
+ }
28
+ else {
29
+ collectRefs(val, refs);
30
+ }
31
+ }
32
+ }
33
+ // Extract schema name from a $ref like "#/components/schemas/Foo"
34
+ function schemaNameFromRef(ref) {
35
+ const prefix = "#/components/schemas/";
36
+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : null;
37
+ }
38
+ // Collect initial refs from paths (not from components to avoid circular bootstrapping)
39
+ const pathRefs = new Set();
40
+ collectRefs(spec.paths, pathRefs);
41
+ // BFS to transitively follow refs within referenced schemas
42
+ const referenced = new Set();
43
+ const queue = [];
44
+ for (const ref of pathRefs) {
45
+ const name = schemaNameFromRef(ref);
46
+ if (name && schemas[name] && !referenced.has(name)) {
47
+ referenced.add(name);
48
+ queue.push(name);
49
+ }
50
+ }
51
+ while (queue.length > 0) {
52
+ const name = queue.pop();
53
+ const inner = new Set();
54
+ collectRefs(schemas[name], inner);
55
+ for (const ref of inner) {
56
+ const refName = schemaNameFromRef(ref);
57
+ if (refName && schemas[refName] && !referenced.has(refName)) {
58
+ referenced.add(refName);
59
+ queue.push(refName);
60
+ }
61
+ }
62
+ }
63
+ // Build cleaned spec — shallow clone, then rebuild components/schemas with only referenced entries
64
+ const cleaned = { ...spec };
65
+ cleaned.components = { ...spec.components };
66
+ if (referenced.size === 0) {
67
+ delete cleaned.components.schemas;
68
+ }
69
+ else {
70
+ cleaned.components.schemas = {};
71
+ for (const name of referenced) {
72
+ cleaned.components.schemas[name] = schemas[name];
73
+ }
74
+ }
75
+ if (Object.keys(cleaned.components).length === 0) {
76
+ delete cleaned.components;
77
+ }
78
+ return cleaned;
79
+ }
@@ -28,7 +28,7 @@ export const createTenant = async (tenantId, options) => {
28
28
  }
29
29
  if (existing && existing.deletedAt) {
30
30
  // Reactivate soft-deleted tenant
31
- await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: null, displayName: options?.displayName, config: options?.config });
31
+ await Tenant.findOneAndUpdate({ tenantId }, { $set: { deletedAt: null, displayName: options?.displayName, config: options?.config } });
32
32
  return;
33
33
  }
34
34
  await Tenant.create({
@@ -40,7 +40,7 @@ export const createTenant = async (tenantId, options) => {
40
40
  export const deleteTenant = async (tenantId) => {
41
41
  const { invalidateTenantCache } = await import("../middleware/tenant");
42
42
  // Soft-delete
43
- await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: new Date() });
43
+ await Tenant.findOneAndUpdate({ tenantId }, { $set: { deletedAt: new Date() } });
44
44
  invalidateTenantCache(tenantId);
45
45
  };
46
46
  export const getTenant = async (tenantId) => {
@@ -0,0 +1,35 @@
1
+ import type { Context } from "hono";
2
+ import type { AppEnv } from "./context";
3
+ import type { StorageAdapter, UploadResult } from "./storageAdapter";
4
+ export interface UploadOpts {
5
+ field?: string | string[];
6
+ maxFileSize?: number;
7
+ maxFiles?: number;
8
+ allowedMimeTypes?: string[];
9
+ keyPrefix?: string;
10
+ generateKey?: (file: File, ctx: {
11
+ userId?: string;
12
+ tenantId?: string;
13
+ }) => string;
14
+ tenantScopedKeys?: boolean;
15
+ }
16
+ export declare const setStorageAdapter: (adapter: StorageAdapter) => void;
17
+ export declare const getStorageAdapter: () => StorageAdapter | null;
18
+ export declare const setUploadConfig: (config: UploadOpts) => void;
19
+ export declare const getUploadConfig: () => UploadOpts;
20
+ export declare const generateUploadKey: (file: File, ctx: {
21
+ userId?: string;
22
+ tenantId?: string;
23
+ }, opts?: UploadOpts) => string;
24
+ export declare const validateFile: (file: File, opts: {
25
+ maxFileSize?: number;
26
+ allowedMimeTypes?: string[];
27
+ }) => string | null;
28
+ export declare const processUpload: (file: File, opts: UploadOpts & {
29
+ ctx?: {
30
+ userId?: string;
31
+ tenantId?: string;
32
+ };
33
+ bucket?: string;
34
+ }) => Promise<UploadResult>;
35
+ export declare const parseUpload: (c: Context<AppEnv>, opts?: UploadOpts) => Promise<UploadResult[]>;
@@ -0,0 +1,87 @@
1
+ import { HttpError } from "./HttpError";
2
+ import { extname } from "node:path";
3
+ let _adapter = null;
4
+ let _config = {};
5
+ export const setStorageAdapter = (adapter) => { _adapter = adapter; };
6
+ export const getStorageAdapter = () => _adapter;
7
+ export const setUploadConfig = (config) => { _config = config; };
8
+ export const getUploadConfig = () => _config;
9
+ export const generateUploadKey = (file, ctx, opts) => {
10
+ const merged = { ..._config, ...opts };
11
+ if (merged.generateKey)
12
+ return merged.generateKey(file, ctx);
13
+ const ext = extname(file.name);
14
+ const uuid = crypto.randomUUID();
15
+ const prefix = merged.keyPrefix ?? "uploads/";
16
+ const tenantPrefix = merged.tenantScopedKeys && ctx.tenantId ? `${ctx.tenantId}/` : "";
17
+ return `${prefix}${tenantPrefix}${uuid}${ext}`;
18
+ };
19
+ const mimeMatches = (mimeType, pattern) => {
20
+ if (pattern.endsWith("/*")) {
21
+ return mimeType.startsWith(pattern.slice(0, -1));
22
+ }
23
+ return mimeType === pattern;
24
+ };
25
+ export const validateFile = (file, opts) => {
26
+ const maxFileSize = opts.maxFileSize ?? _config.maxFileSize ?? 10 * 1024 * 1024;
27
+ if (file.size > maxFileSize) {
28
+ return `File "${file.name}" exceeds maximum size of ${maxFileSize} bytes`;
29
+ }
30
+ const allowedMimeTypes = opts.allowedMimeTypes ?? _config.allowedMimeTypes;
31
+ if (allowedMimeTypes && allowedMimeTypes.length > 0) {
32
+ const allowed = allowedMimeTypes.some((pattern) => mimeMatches(file.type, pattern));
33
+ if (!allowed) {
34
+ return `File "${file.name}" has disallowed MIME type "${file.type}"`;
35
+ }
36
+ }
37
+ return null;
38
+ };
39
+ export const processUpload = async (file, opts) => {
40
+ const adapter = _adapter;
41
+ if (!adapter)
42
+ throw new HttpError(500, "No storage adapter configured");
43
+ const validationError = validateFile(file, opts);
44
+ if (validationError)
45
+ throw new HttpError(400, validationError);
46
+ const key = generateUploadKey(file, opts.ctx ?? {}, opts);
47
+ const { url } = await adapter.put(key, file, {
48
+ mimeType: file.type,
49
+ size: file.size,
50
+ bucket: opts.bucket,
51
+ });
52
+ return {
53
+ key,
54
+ originalName: file.name,
55
+ mimeType: file.type,
56
+ size: file.size,
57
+ ...(url !== undefined ? { url } : {}),
58
+ };
59
+ };
60
+ export const parseUpload = async (c, opts) => {
61
+ const merged = { ..._config, ...opts };
62
+ const fields = merged.field
63
+ ? (Array.isArray(merged.field) ? merged.field : [merged.field])
64
+ : ["file"];
65
+ const maxFiles = merged.maxFiles ?? 10;
66
+ const body = await c.req.parseBody({ all: true });
67
+ const results = [];
68
+ const userId = c.get("authUserId") ?? undefined;
69
+ const tenantId = c.get("tenantId") ?? undefined;
70
+ const bucket = c.get("uploadBucket");
71
+ for (const field of fields) {
72
+ const raw = body[field];
73
+ if (!raw)
74
+ continue;
75
+ const files = Array.isArray(raw) ? raw : [raw];
76
+ for (const f of files) {
77
+ if (!(f instanceof File))
78
+ continue;
79
+ if (results.length >= maxFiles) {
80
+ throw new HttpError(400, `Too many files. Maximum is ${maxFiles}`);
81
+ }
82
+ const result = await processUpload(f, { ...merged, ctx: { userId, tenantId }, bucket: bucket ?? undefined });
83
+ results.push(result);
84
+ }
85
+ }
86
+ return results;
87
+ };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { HttpError } from "./HttpError";
2
+ import { ValidationError } from "./HttpError";
3
3
  export const validate = async (schema, req) => {
4
4
  try {
5
5
  const body = await req.json();
@@ -7,7 +7,7 @@ export const validate = async (schema, req) => {
7
7
  }
8
8
  catch (err) {
9
9
  if (err instanceof z.ZodError) {
10
- throw new HttpError(400, err.issues.map((i) => i.message).join(", "));
10
+ throw new ValidationError(err.issues);
11
11
  }
12
12
  throw err;
13
13
  }
package/dist/lib/ws.d.ts CHANGED
@@ -6,6 +6,7 @@ type WithSocketId = {
6
6
  id: string;
7
7
  } & WithRooms;
8
8
  export declare const setWsServer: (server: Server<any>) => void;
9
+ export declare const setPresenceEnabled: (enabled: boolean) => void;
9
10
  export declare const publish: (topic: string, data: unknown) => void;
10
11
  /** All rooms that currently have at least one subscriber */
11
12
  export declare const getRooms: () => string[];
package/dist/lib/ws.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { addPresence, removePresence, cleanupPresence } from "./wsPresence";
1
2
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
3
  let _server = null;
4
+ let _presenceEnabled = false;
3
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
6
  export const setWsServer = (server) => { _server = server; };
7
+ export const setPresenceEnabled = (enabled) => { _presenceEnabled = enabled; };
5
8
  export const publish = (topic, data) => {
6
9
  _server?.publish(topic, JSON.stringify(data));
7
10
  };
@@ -43,6 +46,12 @@ export const subscribe = (ws, room) => {
43
46
  if (!_roomRegistry.has(room))
44
47
  _roomRegistry.set(room, new Set());
45
48
  _roomRegistry.get(room).add(ws.data.id);
49
+ if (_presenceEnabled) {
50
+ const result = addPresence(ws.data.id, room);
51
+ if (result?.isNewUser) {
52
+ publish(room, { event: "presence_join", room, userId: result.userId });
53
+ }
54
+ }
46
55
  };
47
56
  export const unsubscribe = (ws, room) => {
48
57
  ws.unsubscribe(room);
@@ -53,10 +62,22 @@ export const unsubscribe = (ws, room) => {
53
62
  if (ids.size === 0)
54
63
  _roomRegistry.delete(room);
55
64
  }
65
+ if (_presenceEnabled) {
66
+ const result = removePresence(ws.data.id, room);
67
+ if (result?.isLastSocket) {
68
+ publish(room, { event: "presence_leave", room, userId: result.userId });
69
+ }
70
+ }
56
71
  };
57
72
  export const getSubscriptions = (ws) => [...ws.data.rooms];
58
73
  /** Called on socket close to prune the registry. Internal use only. */
59
74
  export const cleanupSocket = (socketId, rooms) => {
75
+ if (_presenceEnabled) {
76
+ const departed = cleanupPresence(socketId, rooms);
77
+ for (const { room, userId } of departed) {
78
+ publish(room, { event: "presence_leave", room, userId });
79
+ }
80
+ }
60
81
  for (const room of rooms) {
61
82
  const ids = _roomRegistry.get(room);
62
83
  if (ids) {
@@ -0,0 +1,12 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ export interface HeartbeatConfig {
3
+ intervalMs?: number;
4
+ timeoutMs?: number;
5
+ }
6
+ export declare const registerSocket: (ws: ServerWebSocket<any>, id: string) => void;
7
+ export declare const deregisterSocket: (id: string) => void;
8
+ export declare const handlePong: (id: string) => void;
9
+ export declare const startHeartbeat: (config?: HeartbeatConfig | boolean) => void;
10
+ export declare const stopHeartbeat: () => void;
11
+ /** Reset all heartbeat state. Useful for test isolation. */
12
+ export declare const clearHeartbeatState: () => void;