@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.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +44 -0
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +144 -0
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +120 -0
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +7 -0
- package/dist/adapters/sqliteAuth.js +199 -0
- package/dist/app.d.ts +100 -3
- package/dist/app.js +248 -47
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +49 -7
- package/dist/index.js +35 -5
- package/dist/lib/HttpError.d.ts +5 -0
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.d.ts +44 -0
- package/dist/lib/appConfig.js +16 -0
- package/dist/lib/auditLog.d.ts +52 -0
- package/dist/lib/auditLog.js +201 -0
- package/dist/lib/authAdapter.d.ts +69 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +19 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/session.d.ts +4 -0
- package/dist/lib/session.js +56 -2
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +180 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +35 -0
- package/dist/lib/upload.js +87 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +21 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/csrf.js +10 -0
- package/dist/middleware/identify.js +57 -9
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +99 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +36 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +57 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/routes/auth.js +84 -6
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +47 -45
- package/dist/routes/metrics.d.ts +7 -0
- package/dist/routes/metrics.js +52 -0
- package/dist/routes/mfa.js +4 -0
- package/dist/routes/uploads.d.ts +2 -0
- package/dist/routes/uploads.js +135 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/ws/index.js +3 -0
- package/docs/sections/auth-flow/full.md +779 -634
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +365 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +127 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +199 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +83 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +16 -4
package/dist/lib/session.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/tenant.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/lib/validate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
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
|
|
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;
|