@lastshotlabs/bunshot 0.0.21 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -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/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- 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/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -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/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -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/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- 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 +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- 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 +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -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/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -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 +131 -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/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -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 +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -20,10 +20,34 @@ const memoryStore = {
|
|
|
20
20
|
async delete(key) {
|
|
21
21
|
_memoryStore.delete(key);
|
|
22
22
|
},
|
|
23
|
+
// No increment — memory store uses the read-modify-write fallback (single-process, acceptable)
|
|
23
24
|
};
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Redis implementation
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
28
|
+
// Lua script: atomically read + increment + write JSON entry, preserving { count, resetAt } format.
|
|
29
|
+
// Returns the new count as a number.
|
|
30
|
+
const TRACK_SCRIPT = `
|
|
31
|
+
local key = KEYS[1]
|
|
32
|
+
local windowMs = tonumber(ARGV[1])
|
|
33
|
+
local now = tonumber(ARGV[2])
|
|
34
|
+
local raw = redis.call("GET", key)
|
|
35
|
+
local count, resetAt
|
|
36
|
+
|
|
37
|
+
if raw then
|
|
38
|
+
local entry = cjson.decode(raw)
|
|
39
|
+
count = entry.count + 1
|
|
40
|
+
resetAt = entry.resetAt
|
|
41
|
+
else
|
|
42
|
+
count = 1
|
|
43
|
+
resetAt = now + windowMs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local ttl = math.max(1, resetAt - now)
|
|
47
|
+
local payload = cjson.encode({count = count, resetAt = resetAt})
|
|
48
|
+
redis.call("SET", key, payload, "PX", ttl)
|
|
49
|
+
return count
|
|
50
|
+
`;
|
|
27
51
|
const redisStore = {
|
|
28
52
|
async get(key) {
|
|
29
53
|
const { getRedis } = await import("./redis");
|
|
@@ -43,6 +67,13 @@ const redisStore = {
|
|
|
43
67
|
const { getRedis } = await import("./redis");
|
|
44
68
|
await getRedis().del(`rl:${getAppName()}:${key}`);
|
|
45
69
|
},
|
|
70
|
+
async increment(key, windowMs) {
|
|
71
|
+
const { getRedis } = await import("./redis");
|
|
72
|
+
const fullKey = `rl:${getAppName()}:${key}`;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const count = await getRedis().eval(TRACK_SCRIPT, 1, fullKey, windowMs, now);
|
|
75
|
+
return count;
|
|
76
|
+
},
|
|
46
77
|
};
|
|
47
78
|
// ---------------------------------------------------------------------------
|
|
48
79
|
// Active store + setter
|
|
@@ -60,6 +91,11 @@ export const isLimited = async (key, opts) => {
|
|
|
60
91
|
};
|
|
61
92
|
/** Increments the counter and returns true if now over the limit. */
|
|
62
93
|
export const trackAttempt = async (key, opts) => {
|
|
94
|
+
if (_store.increment) {
|
|
95
|
+
const count = await _store.increment(key, opts.windowMs);
|
|
96
|
+
return count >= opts.max;
|
|
97
|
+
}
|
|
98
|
+
// Read-modify-write fallback for memory store (single-process — no lost increments)
|
|
63
99
|
const now = Date.now();
|
|
64
100
|
const existing = await _store.get(key);
|
|
65
101
|
if (!existing) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BreachedPasswordConfig } from "./appConfig";
|
|
2
|
+
export type { BreachedPasswordConfig };
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a password has appeared in a data breach using the
|
|
5
|
+
* HaveIBeenPwned k-Anonymity API.
|
|
6
|
+
*
|
|
7
|
+
* Sends only the first 5 hex chars of the SHA-1 hash — the full hash
|
|
8
|
+
* never leaves the server.
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkBreachedPassword(password: string, config?: BreachedPasswordConfig): Promise<{
|
|
11
|
+
breached: boolean;
|
|
12
|
+
count: number;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether a password has appeared in a data breach using the
|
|
3
|
+
* HaveIBeenPwned k-Anonymity API.
|
|
4
|
+
*
|
|
5
|
+
* Sends only the first 5 hex chars of the SHA-1 hash — the full hash
|
|
6
|
+
* never leaves the server.
|
|
7
|
+
*/
|
|
8
|
+
export async function checkBreachedPassword(password, config) {
|
|
9
|
+
const timeout = config?.timeout ?? 3000;
|
|
10
|
+
const minCount = config?.minBreachCount ?? 1;
|
|
11
|
+
// SHA-1 the password
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const data = encoder.encode(password);
|
|
14
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
|
15
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
16
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
17
|
+
const prefix = hashHex.slice(0, 5);
|
|
18
|
+
const suffix = hashHex.slice(5);
|
|
19
|
+
let responseText;
|
|
20
|
+
try {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
23
|
+
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
headers: { "Add-Padding": "true" },
|
|
26
|
+
});
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(`HIBP API returned ${res.status}`);
|
|
30
|
+
responseText = await res.text();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// API failure — apply onApiFailure policy
|
|
34
|
+
if (config?.onApiFailure === "block") {
|
|
35
|
+
return { breached: true, count: -1 };
|
|
36
|
+
}
|
|
37
|
+
return { breached: false, count: 0 };
|
|
38
|
+
}
|
|
39
|
+
// Parse response: each line is "SUFFIX:COUNT"
|
|
40
|
+
for (const line of responseText.split("\n")) {
|
|
41
|
+
const [lineSuffix, countStr] = line.trim().split(":");
|
|
42
|
+
if (lineSuffix === suffix) {
|
|
43
|
+
const count = parseInt(countStr, 10);
|
|
44
|
+
return { breached: count >= minCount, count };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { breached: false, count: 0 };
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CaptchaProvider = "recaptcha" | "hcaptcha" | "turnstile";
|
|
2
|
+
export interface CaptchaConfig {
|
|
3
|
+
provider: CaptchaProvider;
|
|
4
|
+
secretKey: string;
|
|
5
|
+
/** Minimum score for reCAPTCHA v3. Default: 0.5. Ignored for v2/hcaptcha/turnstile. */
|
|
6
|
+
minScore?: number;
|
|
7
|
+
/** Body field name containing the CAPTCHA token. Default: "captcha-token". */
|
|
8
|
+
tokenField?: string;
|
|
9
|
+
/** When true, only require CAPTCHA after rate limit threshold is hit. Default: false. */
|
|
10
|
+
adaptive?: boolean;
|
|
11
|
+
/** Rate limit window for adaptive mode. */
|
|
12
|
+
adaptiveThreshold?: {
|
|
13
|
+
windowMs: number;
|
|
14
|
+
max: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Verify a CAPTCHA token with the provider's API.
|
|
19
|
+
* Returns { success: true } on pass, { success: false, error } on fail.
|
|
20
|
+
*/
|
|
21
|
+
export declare function verifyCaptcha(token: string, config: CaptchaConfig, ip?: string): Promise<{
|
|
22
|
+
success: boolean;
|
|
23
|
+
score?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const VERIFY_URLS = {
|
|
2
|
+
recaptcha: "https://www.google.com/recaptcha/api/siteverify",
|
|
3
|
+
hcaptcha: "https://hcaptcha.com/siteverify",
|
|
4
|
+
turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Verify a CAPTCHA token with the provider's API.
|
|
8
|
+
* Returns { success: true } on pass, { success: false, error } on fail.
|
|
9
|
+
*/
|
|
10
|
+
export async function verifyCaptcha(token, config, ip) {
|
|
11
|
+
const url = VERIFY_URLS[config.provider];
|
|
12
|
+
const body = new URLSearchParams({ secret: config.secretKey, response: token });
|
|
13
|
+
if (ip)
|
|
14
|
+
body.set("remoteip", ip);
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(url, { method: "POST", body });
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return { success: false, error: `Provider returned ${res.status}` };
|
|
20
|
+
data = await res.json();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return { success: false, error: "CAPTCHA provider unreachable" };
|
|
24
|
+
}
|
|
25
|
+
if (!data.success) {
|
|
26
|
+
return { success: false, error: String(data["error-codes"]?.[0] ?? "invalid-token") };
|
|
27
|
+
}
|
|
28
|
+
// reCAPTCHA v3: check score
|
|
29
|
+
if (config.provider === "recaptcha" && typeof data.score === "number") {
|
|
30
|
+
const minScore = config.minScore ?? 0.5;
|
|
31
|
+
if (data.score < minScore) {
|
|
32
|
+
return { success: false, score: data.score, error: "score-too-low" };
|
|
33
|
+
}
|
|
34
|
+
return { success: true, score: data.score };
|
|
35
|
+
}
|
|
36
|
+
return { success: true };
|
|
37
|
+
}
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -4,3 +4,7 @@ export declare const COOKIE_REFRESH_TOKEN = "refresh_token";
|
|
|
4
4
|
export declare const HEADER_REFRESH_TOKEN = "x-refresh-token";
|
|
5
5
|
export declare const COOKIE_CSRF_TOKEN = "csrf_token";
|
|
6
6
|
export declare const HEADER_CSRF_TOKEN = "x-csrf-token";
|
|
7
|
+
export declare const HEADER_REQUEST_ID = "x-request-id";
|
|
8
|
+
export declare const HEADER_IDEMPOTENCY_KEY = "idempotency-key";
|
|
9
|
+
export declare const HEADER_SIGNATURE = "x-signature";
|
|
10
|
+
export declare const HEADER_TIMESTAMP = "x-timestamp";
|
package/dist/lib/constants.js
CHANGED
|
@@ -4,3 +4,7 @@ export const COOKIE_REFRESH_TOKEN = "refresh_token";
|
|
|
4
4
|
export const HEADER_REFRESH_TOKEN = "x-refresh-token";
|
|
5
5
|
export const COOKIE_CSRF_TOKEN = "csrf_token";
|
|
6
6
|
export const HEADER_CSRF_TOKEN = "x-csrf-token";
|
|
7
|
+
export const HEADER_REQUEST_ID = "x-request-id";
|
|
8
|
+
export const HEADER_IDEMPOTENCY_KEY = "idempotency-key";
|
|
9
|
+
export const HEADER_SIGNATURE = "x-signature";
|
|
10
|
+
export const HEADER_TIMESTAMP = "x-timestamp";
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
|
-
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
1
|
+
import { OpenAPIHono, type Hook } from "@hono/zod-openapi";
|
|
2
|
+
import type { ZodIssue } from "zod";
|
|
3
|
+
import type { JWTPayload } from "jose";
|
|
4
|
+
import type { UploadResult } from "./storageAdapter";
|
|
5
|
+
export interface ValidationErrorDetail {
|
|
6
|
+
path: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DefaultValidationErrorBody {
|
|
10
|
+
error: string;
|
|
11
|
+
details: ValidationErrorDetail[];
|
|
12
|
+
requestId: string;
|
|
13
|
+
}
|
|
14
|
+
export type ValidationErrorFormatter = (issues: ZodIssue[], requestId: string) => unknown;
|
|
15
|
+
export declare const defaultValidationErrorFormatter: ValidationErrorFormatter;
|
|
2
16
|
export type AppVariables = {
|
|
17
|
+
requestId: string;
|
|
3
18
|
authUserId: string | null;
|
|
4
19
|
roles: string[] | null;
|
|
5
20
|
sessionId: string | null;
|
|
6
21
|
tenantId: string | null;
|
|
7
22
|
tenantConfig: Record<string, unknown> | null;
|
|
23
|
+
validationErrorFormatter: ValidationErrorFormatter;
|
|
24
|
+
uploadResults: UploadResult[] | null;
|
|
25
|
+
uploadBucket: string | undefined;
|
|
26
|
+
/** Set by identify when a scope-bearing M2M token (no sid) is verified. */
|
|
27
|
+
authClientId: string | null;
|
|
28
|
+
/** Raw verified JWT payload stashed by identify for downstream middleware. Null when unauthenticated. */
|
|
29
|
+
tokenPayload: JWTPayload | null;
|
|
8
30
|
};
|
|
9
31
|
export type AppEnv = {
|
|
10
32
|
Variables: AppVariables;
|
|
11
33
|
};
|
|
34
|
+
export declare const defaultHook: Hook<any, AppEnv, any, any>;
|
|
12
35
|
export declare const createRouter: () => OpenAPIHono<AppEnv, {}, "/">;
|
package/dist/lib/context.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
-
const
|
|
2
|
+
export const defaultValidationErrorFormatter = (issues, requestId) => {
|
|
3
|
+
const error = issues.map((i) => i.message).join(", ");
|
|
4
|
+
const details = issues.map((i) => ({
|
|
5
|
+
path: i.path.join("."),
|
|
6
|
+
message: i.message,
|
|
7
|
+
}));
|
|
8
|
+
return { error, details, requestId };
|
|
9
|
+
};
|
|
10
|
+
export const defaultHook = (result, c) => {
|
|
3
11
|
if (!result.success) {
|
|
4
|
-
const
|
|
5
|
-
|
|
12
|
+
const requestId = c.get("requestId") ?? "unknown";
|
|
13
|
+
const formatter = c.get("validationErrorFormatter") ?? defaultValidationErrorFormatter;
|
|
14
|
+
try {
|
|
15
|
+
return c.json(formatter(result.error.issues, requestId), 400);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return c.json(defaultValidationErrorFormatter(result.error.issues, requestId), 400);
|
|
19
|
+
}
|
|
6
20
|
}
|
|
7
21
|
};
|
|
8
22
|
export const createRouter = () => new OpenAPIHono({ defaultHook });
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import type { RouteConfig } from "@hono/zod-openapi";
|
|
2
2
|
import type { ZodType } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Sets the active version prefix for schema name generation and creates a unique
|
|
5
|
+
* Symbol token for interleaving detection. Call before importing a version's route files.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setVersionPrefix(version: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Clears the active version prefix and token. Call after each version's route files
|
|
10
|
+
* have been fully imported.
|
|
11
|
+
*/
|
|
12
|
+
export declare function clearVersionPrefix(): void;
|
|
13
|
+
/** Returns the current version token for assertion after import. */
|
|
14
|
+
export declare function getVersionToken(): symbol | null;
|
|
15
|
+
/**
|
|
16
|
+
* Drains and returns all tokens captured by createRoute() calls since the last drain.
|
|
17
|
+
* Used by versioned route discovery to detect interleaving.
|
|
18
|
+
*/
|
|
19
|
+
export declare function drainCapturedTokens(): (symbol | null)[];
|
|
20
|
+
/**
|
|
21
|
+
* Asserts that all tokens in the array match the expected token.
|
|
22
|
+
* Throws a clear startup error if any mismatch is detected.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertCapturedTokens(tokens: (symbol | null)[], expectedToken: symbol | null): void;
|
|
3
25
|
/**
|
|
4
26
|
* Registers a Zod schema as a named entry in `components/schemas`.
|
|
5
27
|
*
|
|
@@ -53,9 +75,13 @@ export declare const withSecurity: <T extends RouteConfig>(route: T, ...schemes:
|
|
|
53
75
|
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
54
76
|
* inlined at every use site. Generated names follow the convention:
|
|
55
77
|
*
|
|
56
|
-
* {Method}{PathSegments}
|
|
57
|
-
* {Method}{PathSegments}{
|
|
78
|
+
* {Version}{Method}{PathSegments}Request — request body (when versioning is active)
|
|
79
|
+
* {Version}{Method}{PathSegments}{Status} — response body (when versioning is active)
|
|
58
80
|
*
|
|
59
81
|
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
82
|
+
*
|
|
83
|
+
* When `setVersionPrefix` has been called, the version prefix is prepended to all
|
|
84
|
+
* generated schema names and the current version token is captured for interleaving
|
|
85
|
+
* detection via `drainCapturedTokens()` / `assertCapturedTokens()`.
|
|
60
86
|
*/
|
|
61
87
|
export declare const createRoute: <T extends RouteConfig>(config: T) => T;
|
package/dist/lib/createRoute.js
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
import { createRoute as _createRoute } from "@hono/zod-openapi";
|
|
2
2
|
import { getRefId, zodToOpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Version prefix state — set once per version batch before importing route files
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
let _versionPrefix = "";
|
|
7
|
+
let _versionToken = null;
|
|
8
|
+
/** Tokens captured by createRoute() calls since the last drainCapturedTokens() call. */
|
|
9
|
+
const _capturedTokens = [];
|
|
10
|
+
/**
|
|
11
|
+
* Sets the active version prefix for schema name generation and creates a unique
|
|
12
|
+
* Symbol token for interleaving detection. Call before importing a version's route files.
|
|
13
|
+
*/
|
|
14
|
+
export function setVersionPrefix(version) {
|
|
15
|
+
_versionPrefix = version.charAt(0).toUpperCase() + version.slice(1);
|
|
16
|
+
_versionToken = Symbol(version);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Clears the active version prefix and token. Call after each version's route files
|
|
20
|
+
* have been fully imported.
|
|
21
|
+
*/
|
|
22
|
+
export function clearVersionPrefix() {
|
|
23
|
+
_versionPrefix = "";
|
|
24
|
+
_versionToken = null;
|
|
25
|
+
}
|
|
26
|
+
/** Returns the current version token for assertion after import. */
|
|
27
|
+
export function getVersionToken() {
|
|
28
|
+
return _versionToken;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Drains and returns all tokens captured by createRoute() calls since the last drain.
|
|
32
|
+
* Used by versioned route discovery to detect interleaving.
|
|
33
|
+
*/
|
|
34
|
+
export function drainCapturedTokens() {
|
|
35
|
+
return _capturedTokens.splice(0);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Asserts that all tokens in the array match the expected token.
|
|
39
|
+
* Throws a clear startup error if any mismatch is detected.
|
|
40
|
+
*/
|
|
41
|
+
export function assertCapturedTokens(tokens, expectedToken) {
|
|
42
|
+
for (const tok of tokens) {
|
|
43
|
+
if (tok !== expectedToken) {
|
|
44
|
+
throw new Error("Route file imported with wrong version prefix — avoid unbounded top-level await in versioned route files");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
3
48
|
const STATUS_SUFFIX = {
|
|
4
49
|
"200": "Response",
|
|
5
50
|
"201": "Response",
|
|
@@ -128,13 +173,19 @@ export const withSecurity = (route, ...schemes) => Object.assign(route, { securi
|
|
|
128
173
|
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
129
174
|
* inlined at every use site. Generated names follow the convention:
|
|
130
175
|
*
|
|
131
|
-
* {Method}{PathSegments}
|
|
132
|
-
* {Method}{PathSegments}{
|
|
176
|
+
* {Version}{Method}{PathSegments}Request — request body (when versioning is active)
|
|
177
|
+
* {Version}{Method}{PathSegments}{Status} — response body (when versioning is active)
|
|
133
178
|
*
|
|
134
179
|
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
180
|
+
*
|
|
181
|
+
* When `setVersionPrefix` has been called, the version prefix is prepended to all
|
|
182
|
+
* generated schema names and the current version token is captured for interleaving
|
|
183
|
+
* detection via `drainCapturedTokens()` / `assertCapturedTokens()`.
|
|
135
184
|
*/
|
|
136
185
|
export const createRoute = (config) => {
|
|
137
|
-
const base = toBaseName(config.method, config.path);
|
|
186
|
+
const base = (_versionPrefix ? `${_versionPrefix}` : "") + toBaseName(config.method, config.path);
|
|
187
|
+
// Capture the current version token for interleaving detection
|
|
188
|
+
_capturedTokens.push(_versionToken);
|
|
138
189
|
// Auto-name the JSON request body schema if present and unnamed
|
|
139
190
|
const bodySchema = config.request?.body?.content?.["application/json"]?.schema;
|
|
140
191
|
maybeRegister(bodySchema, `${base}Request`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface CredentialStuffingConfig {
|
|
2
|
+
/** Block when an IP attempts login against this many distinct accounts. Default: 5 per 15 min. */
|
|
3
|
+
maxAccountsPerIp?: {
|
|
4
|
+
count: number;
|
|
5
|
+
windowMs: number;
|
|
6
|
+
};
|
|
7
|
+
/** Block when an account is attempted from this many distinct IPs. Default: 10 per 15 min. */
|
|
8
|
+
maxIpsPerAccount?: {
|
|
9
|
+
count: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
};
|
|
12
|
+
/** Called when stuffing is detected. Non-blocking, errors swallowed. */
|
|
13
|
+
onDetected?: (signal: {
|
|
14
|
+
type: "ip" | "account";
|
|
15
|
+
key: string;
|
|
16
|
+
count: number;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function setCredentialStuffingConfig(config: CredentialStuffingConfig | null): void;
|
|
20
|
+
export declare function getCredentialStuffingConfig(): CredentialStuffingConfig | null;
|
|
21
|
+
/**
|
|
22
|
+
* Track a failed login attempt. Call this AFTER confirming the login failed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function trackFailedLogin(ip: string, identifier: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Check whether this login attempt should be blocked.
|
|
27
|
+
* Call this BEFORE verifying credentials.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStuffingBlocked(ip: string, identifier: string): boolean;
|
|
30
|
+
/** Clear the in-memory store (for testing). */
|
|
31
|
+
export declare function clearCredentialStuffingStore(): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// In-memory store: key → BoundedSet
|
|
2
|
+
const _store = new Map();
|
|
3
|
+
function cleanExpired() {
|
|
4
|
+
const now = Date.now();
|
|
5
|
+
for (const [key, entry] of _store.entries()) {
|
|
6
|
+
if (entry.expiresAt < now)
|
|
7
|
+
_store.delete(key);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function addToSet(key, member, windowMs) {
|
|
11
|
+
cleanExpired();
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
let entry = _store.get(key);
|
|
14
|
+
if (!entry || entry.expiresAt < now) {
|
|
15
|
+
entry = { members: new Set(), expiresAt: now + windowMs };
|
|
16
|
+
_store.set(key, entry);
|
|
17
|
+
}
|
|
18
|
+
entry.members.add(member);
|
|
19
|
+
return entry.members.size;
|
|
20
|
+
}
|
|
21
|
+
function getSetSize(key) {
|
|
22
|
+
cleanExpired();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const entry = _store.get(key);
|
|
25
|
+
if (!entry || entry.expiresAt < now)
|
|
26
|
+
return 0;
|
|
27
|
+
return entry.members.size;
|
|
28
|
+
}
|
|
29
|
+
let _config = null;
|
|
30
|
+
export function setCredentialStuffingConfig(config) {
|
|
31
|
+
_config = config;
|
|
32
|
+
}
|
|
33
|
+
export function getCredentialStuffingConfig() {
|
|
34
|
+
return _config;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Track a failed login attempt. Call this AFTER confirming the login failed.
|
|
38
|
+
*/
|
|
39
|
+
export function trackFailedLogin(ip, identifier) {
|
|
40
|
+
if (!_config)
|
|
41
|
+
return;
|
|
42
|
+
const ipWindowMs = _config.maxAccountsPerIp?.windowMs ?? 15 * 60 * 1000;
|
|
43
|
+
const accountWindowMs = _config.maxIpsPerAccount?.windowMs ?? 15 * 60 * 1000;
|
|
44
|
+
addToSet(`ip:${ip}`, identifier, ipWindowMs);
|
|
45
|
+
addToSet(`account:${identifier}`, ip, accountWindowMs);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check whether this login attempt should be blocked.
|
|
49
|
+
* Call this BEFORE verifying credentials.
|
|
50
|
+
*/
|
|
51
|
+
export function isStuffingBlocked(ip, identifier) {
|
|
52
|
+
if (!_config)
|
|
53
|
+
return false;
|
|
54
|
+
const ipMax = _config.maxAccountsPerIp?.count ?? 5;
|
|
55
|
+
const accountMax = _config.maxIpsPerAccount?.count ?? 10;
|
|
56
|
+
const ipCount = getSetSize(`ip:${ip}`);
|
|
57
|
+
if (ipCount >= ipMax) {
|
|
58
|
+
try {
|
|
59
|
+
_config.onDetected?.({ type: "ip", key: ip, count: ipCount });
|
|
60
|
+
}
|
|
61
|
+
catch { /* swallow */ }
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const accountCount = getSetSize(`account:${identifier}`);
|
|
65
|
+
if (accountCount >= accountMax) {
|
|
66
|
+
try {
|
|
67
|
+
_config.onDetected?.({ type: "account", key: identifier, count: accountCount });
|
|
68
|
+
}
|
|
69
|
+
catch { /* swallow */ }
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Clear the in-memory store (for testing). */
|
|
75
|
+
export function clearCredentialStuffingStore() {
|
|
76
|
+
_store.clear();
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type CancelStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
|
+
export declare const setDeletionCancelTokenStore: (store: CancelStore) => void;
|
|
3
|
+
/** Create a cancel token. Returns the raw token (to embed in the cancel link).
|
|
4
|
+
* Only the SHA-256 hash is persisted. TTL is gracePeriod + a 5-minute buffer. */
|
|
5
|
+
export declare const createDeletionCancelToken: (userId: string, jobId: string, gracePeriodSeconds: number) => Promise<string>;
|
|
6
|
+
/** Atomically consume a cancel token — returns its payload and deletes it.
|
|
7
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
8
|
+
export declare const consumeDeletionCancelToken: (token: string) => Promise<{
|
|
9
|
+
userId: string;
|
|
10
|
+
jobId: string;
|
|
11
|
+
} | null>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
import { sqliteCreateDeletionCancelToken, sqliteConsumeDeletionCancelToken, } from "../adapters/sqliteAuth";
|
|
5
|
+
import { memoryCreateDeletionCancelToken, memoryConsumeDeletionCancelToken, } from "../adapters/memoryAuth";
|
|
6
|
+
import { sha256 as hashToken } from "./crypto";
|
|
7
|
+
function getCancelModel() {
|
|
8
|
+
if (appConnection.models["DeletionCancelToken"])
|
|
9
|
+
return appConnection.models["DeletionCancelToken"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const schema = new Schema({
|
|
12
|
+
token: { type: String, required: true, unique: true },
|
|
13
|
+
userId: { type: String, required: true },
|
|
14
|
+
jobId: { type: String, required: true },
|
|
15
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
16
|
+
}, { collection: "deletion_cancel_tokens" });
|
|
17
|
+
return appConnection.model("DeletionCancelToken", schema);
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Redis helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
async function redisGetDel(key) {
|
|
23
|
+
const redis = getRedis();
|
|
24
|
+
if (typeof redis.getdel === "function") {
|
|
25
|
+
try {
|
|
26
|
+
return await redis.getdel(key);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const msg = err?.message ?? "";
|
|
30
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
35
|
+
return result ?? null;
|
|
36
|
+
}
|
|
37
|
+
let _store = "redis";
|
|
38
|
+
export const setDeletionCancelTokenStore = (store) => { _store = store; };
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Create a cancel token. Returns the raw token (to embed in the cancel link).
|
|
43
|
+
* Only the SHA-256 hash is persisted. TTL is gracePeriod + a 5-minute buffer. */
|
|
44
|
+
export const createDeletionCancelToken = async (userId, jobId, gracePeriodSeconds) => {
|
|
45
|
+
const token = crypto.randomUUID();
|
|
46
|
+
const hash = hashToken(token);
|
|
47
|
+
const ttl = gracePeriodSeconds + 300; // 5-min buffer after grace period expires
|
|
48
|
+
if (_store === "memory") {
|
|
49
|
+
memoryCreateDeletionCancelToken(hash, userId, jobId, ttl);
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
if (_store === "sqlite") {
|
|
53
|
+
sqliteCreateDeletionCancelToken(hash, userId, jobId, ttl);
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
if (_store === "mongo") {
|
|
57
|
+
await getCancelModel().create({
|
|
58
|
+
token: hash,
|
|
59
|
+
userId,
|
|
60
|
+
jobId,
|
|
61
|
+
expiresAt: new Date(Date.now() + ttl * 1000),
|
|
62
|
+
});
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
65
|
+
await getRedis().set(`delcancel:${getAppName()}:${hash}`, JSON.stringify({ userId, jobId }), "EX", ttl);
|
|
66
|
+
return token;
|
|
67
|
+
};
|
|
68
|
+
/** Atomically consume a cancel token — returns its payload and deletes it.
|
|
69
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
70
|
+
export const consumeDeletionCancelToken = async (token) => {
|
|
71
|
+
const hash = hashToken(token);
|
|
72
|
+
if (_store === "memory")
|
|
73
|
+
return memoryConsumeDeletionCancelToken(hash);
|
|
74
|
+
if (_store === "sqlite")
|
|
75
|
+
return sqliteConsumeDeletionCancelToken(hash);
|
|
76
|
+
if (_store === "mongo") {
|
|
77
|
+
const doc = await getCancelModel()
|
|
78
|
+
.findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } })
|
|
79
|
+
.lean();
|
|
80
|
+
if (!doc)
|
|
81
|
+
return null;
|
|
82
|
+
return { userId: doc.userId, jobId: doc.jobId };
|
|
83
|
+
}
|
|
84
|
+
const raw = await redisGetDel(`delcancel:${getAppName()}:${hash}`);
|
|
85
|
+
if (!raw)
|
|
86
|
+
return null;
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
};
|
|
@@ -10,4 +10,10 @@ export declare const getVerificationToken: (token: string) => Promise<{
|
|
|
10
10
|
} | null>;
|
|
11
11
|
/** Delete a verification token by its raw value. Hashes before lookup. */
|
|
12
12
|
export declare const deleteVerificationToken: (token: string) => Promise<void>;
|
|
13
|
+
/** Atomically consume a verification token — returns its payload and deletes it in one operation.
|
|
14
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
15
|
+
export declare const consumeVerificationToken: (token: string) => Promise<{
|
|
16
|
+
userId: string;
|
|
17
|
+
email: string;
|
|
18
|
+
} | null>;
|
|
13
19
|
export {};
|