@holo-js/security 0.1.4 → 0.1.6

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.
@@ -0,0 +1,1171 @@
1
+ import {
2
+ SecurityCsrfError,
3
+ SecurityRateLimitError,
4
+ ip,
5
+ limit
6
+ } from "./chunk-EWQKJSFA.mjs";
7
+
8
+ // src/csrf.ts
9
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
10
+
11
+ // src/runtime.ts
12
+ import { normalizeCorsConfig, normalizeSecurityConfig } from "@holo-js/config";
13
+ function getSecurityRuntimeState() {
14
+ const runtime = globalThis;
15
+ runtime.__holoSecurityRuntime__ ??= {};
16
+ return runtime.__holoSecurityRuntime__;
17
+ }
18
+ var SecurityRuntimeNotConfiguredError = class extends Error {
19
+ constructor() {
20
+ super("[@holo-js/security] Security runtime is not configured yet.");
21
+ }
22
+ };
23
+ function configureSecurityRuntime(bindings) {
24
+ getSecurityRuntimeState().bindings = bindings ? Object.freeze({
25
+ config: normalizeSecurityConfig(bindings.config),
26
+ cors: normalizeCorsConfig(bindings.cors),
27
+ rateLimitStore: bindings.rateLimitStore,
28
+ csrfSigningKey: bindings.csrfSigningKey,
29
+ defaultKeyResolver: bindings.defaultKeyResolver
30
+ }) : void 0;
31
+ }
32
+ function getSecurityRuntime() {
33
+ const bindings = getSecurityRuntimeState().bindings;
34
+ if (!bindings) {
35
+ throw new SecurityRuntimeNotConfiguredError();
36
+ }
37
+ return bindings;
38
+ }
39
+ function getSecurityRuntimeBindings() {
40
+ return getSecurityRuntimeState().bindings;
41
+ }
42
+ function resetSecurityRuntime() {
43
+ getSecurityRuntimeState().bindings = void 0;
44
+ }
45
+ var securityRuntimeInternals = {
46
+ getSecurityRuntimeState
47
+ };
48
+
49
+ // src/rate-limit.ts
50
+ function encodeBucketPart(value) {
51
+ return encodeURIComponent(value);
52
+ }
53
+ function createLimiterPrefix(limiter) {
54
+ return `limiter:${encodeBucketPart(limiter)}|`;
55
+ }
56
+ function createBucketKey(limiter, key) {
57
+ return `${createLimiterPrefix(limiter)}${encodeBucketPart(key)}`;
58
+ }
59
+ function getRateLimitStore() {
60
+ const store = getSecurityRuntime().rateLimitStore;
61
+ if (!store) {
62
+ throw new Error("[@holo-js/security] Rate-limit store is not configured yet.");
63
+ }
64
+ return store;
65
+ }
66
+ function resolveLimiterConfig(name) {
67
+ const limiter = getSecurityRuntime().config.rateLimit.limiters[name];
68
+ if (!limiter) {
69
+ throw new Error(`[@holo-js/security] Rate limiter "${name}" is not defined in config/security.ts.`);
70
+ }
71
+ return limiter;
72
+ }
73
+ function normalizeResolvedLimiterKey(value, label) {
74
+ if (typeof value === "string" && value.length > 0) {
75
+ return value;
76
+ }
77
+ if (typeof value === "number" && Number.isFinite(value)) {
78
+ return String(value);
79
+ }
80
+ throw new TypeError(`[@holo-js/security] ${label} must resolve a non-empty string key.`);
81
+ }
82
+ function shouldTrustProxyHeaders() {
83
+ const trustedProxy = typeof process !== "undefined" ? process.env.HOLO_SECURITY_TRUST_PROXY?.trim().toLowerCase() : void 0;
84
+ return trustedProxy === "1" || trustedProxy === "true" || trustedProxy === "yes" || trustedProxy === "on";
85
+ }
86
+ async function defaultRateLimitKey(request) {
87
+ const runtimeDefaultKey = await getSecurityRuntime().defaultKeyResolver?.(request);
88
+ if (typeof runtimeDefaultKey !== "undefined" && runtimeDefaultKey !== null) {
89
+ return normalizeResolvedLimiterKey(runtimeDefaultKey, "Default rate limiter key resolver");
90
+ }
91
+ return `ip:${ip(request, shouldTrustProxyHeaders())}`;
92
+ }
93
+ async function resolveLimiterKey(name, limiter, options) {
94
+ if (typeof options.key === "string" && options.key.length > 0) {
95
+ return options.key;
96
+ }
97
+ if (limiter.key) {
98
+ if (!options.request) {
99
+ throw new TypeError(`[@holo-js/security] Rate limiter "${name}" requires a request when using its configured key resolver.`);
100
+ }
101
+ const resolved = await limiter.key({
102
+ request: options.request,
103
+ values: options.values
104
+ });
105
+ return normalizeResolvedLimiterKey(resolved, `Rate limiter "${name}"`);
106
+ }
107
+ if (options.request) {
108
+ return await defaultRateLimitKey(options.request);
109
+ }
110
+ throw new TypeError(`[@holo-js/security] Rate limiter "${name}" requires either an explicit key or a request for the default key resolver.`);
111
+ }
112
+ async function rateLimit(name, options) {
113
+ const limiter = resolveLimiterConfig(name);
114
+ const resolvedKey = await resolveLimiterKey(name, limiter, options);
115
+ const bucketKey = createBucketKey(name, resolvedKey);
116
+ const result = await getRateLimitStore().hit(bucketKey, {
117
+ maxAttempts: limiter.maxAttempts,
118
+ decaySeconds: limiter.decaySeconds
119
+ });
120
+ const snapshot = Object.freeze({
121
+ limiter: name,
122
+ key: resolvedKey,
123
+ attempts: result.snapshot.attempts,
124
+ maxAttempts: limiter.maxAttempts,
125
+ remainingAttempts: Math.max(0, limiter.maxAttempts - result.snapshot.attempts),
126
+ expiresAt: result.snapshot.expiresAt
127
+ });
128
+ const normalizedResult = Object.freeze({
129
+ limited: result.limited,
130
+ snapshot,
131
+ retryAfterSeconds: result.retryAfterSeconds
132
+ });
133
+ if (normalizedResult.limited) {
134
+ throw new SecurityRateLimitError(void 0, {
135
+ retryAfterSeconds: normalizedResult.retryAfterSeconds,
136
+ snapshot
137
+ });
138
+ }
139
+ return normalizedResult;
140
+ }
141
+ async function clearRateLimit(options) {
142
+ const store = getRateLimitStore();
143
+ if (options.all && (options.limiter || options.key)) {
144
+ throw new TypeError("[@holo-js/security] clearRateLimit(...) must use either { all: true } or a scoped limiter/key pair, not both.");
145
+ }
146
+ if (options.all) {
147
+ return await store.clearAll();
148
+ }
149
+ if (!options.limiter) {
150
+ throw new TypeError("[@holo-js/security] clearRateLimit(...) requires a limiter name unless { all: true } is used.");
151
+ }
152
+ if (typeof options.key === "string" && options.key.length > 0) {
153
+ return await store.clear(createBucketKey(options.limiter, options.key));
154
+ }
155
+ return await store.clearByPrefix(createLimiterPrefix(options.limiter));
156
+ }
157
+ var rateLimitInternals = {
158
+ createBucketKey,
159
+ createLimiterPrefix,
160
+ encodeBucketPart,
161
+ defaultRateLimitKey,
162
+ getRateLimitStore,
163
+ normalizeResolvedLimiterKey,
164
+ resolveLimiterConfig,
165
+ resolveLimiterKey
166
+ };
167
+
168
+ // src/csrf.ts
169
+ var generatedTokenCache = /* @__PURE__ */ new WeakMap();
170
+ function parseCookieHeader(header) {
171
+ if (!header) {
172
+ return Object.freeze({});
173
+ }
174
+ const decodeCookiePart = (value) => {
175
+ try {
176
+ return decodeURIComponent(value);
177
+ } catch {
178
+ return void 0;
179
+ }
180
+ };
181
+ const entries = header.split(";").map((segment) => segment.trim()).filter(Boolean).map((segment) => {
182
+ const separator = segment.indexOf("=");
183
+ if (separator <= 0) {
184
+ return void 0;
185
+ }
186
+ const key = decodeCookiePart(segment.slice(0, separator));
187
+ const value = decodeCookiePart(segment.slice(separator + 1));
188
+ if (!key || typeof value === "undefined") {
189
+ return void 0;
190
+ }
191
+ return [key, value];
192
+ }).filter((entry) => !!entry);
193
+ return Object.freeze(Object.fromEntries(entries));
194
+ }
195
+ function serializeCookie(name, value, options = {}) {
196
+ const attributes = [
197
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
198
+ "Path=/",
199
+ "SameSite=Lax"
200
+ ];
201
+ if (options.secure) {
202
+ attributes.push("Secure");
203
+ }
204
+ return attributes.join("; ");
205
+ }
206
+ function isSafeMethod(method) {
207
+ const normalized = method.trim().toUpperCase();
208
+ return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS" || normalized === "TRACE";
209
+ }
210
+ function escapeRegex(value) {
211
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
212
+ }
213
+ function matchesPathPattern(pathname, pattern) {
214
+ const source = `^${escapeRegex(pattern).replaceAll("*", ".*")}$`;
215
+ return new RegExp(source).test(pathname);
216
+ }
217
+ function isExcludedPath(request) {
218
+ const { except } = getSecurityRuntime().config.csrf;
219
+ const pathname = new URL(request.url).pathname;
220
+ return except.some((pattern) => matchesPathPattern(pathname, pattern));
221
+ }
222
+ function normalizeForwardedValue(value) {
223
+ return value.trim().replace(/^"|"$/g, "").toLowerCase();
224
+ }
225
+ function getForwardedProto(request) {
226
+ const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim();
227
+ if (forwardedProto) {
228
+ return normalizeForwardedValue(forwardedProto);
229
+ }
230
+ const forwarded = request.headers.get("forwarded")?.split(",", 1)[0];
231
+ if (!forwarded) {
232
+ return void 0;
233
+ }
234
+ for (const segment of forwarded.split(";")) {
235
+ const [name, value] = segment.split("=", 2);
236
+ if (name?.trim().toLowerCase() === "proto" && value) {
237
+ return normalizeForwardedValue(value);
238
+ }
239
+ }
240
+ return void 0;
241
+ }
242
+ function isSecureRequest(request) {
243
+ return getForwardedProto(request) === "https" || new URL(request.url).protocol === "https:";
244
+ }
245
+ function createCsrfToken() {
246
+ return randomBytes(32).toString("base64url");
247
+ }
248
+ function getCsrfSigningKey() {
249
+ const configured = getSecurityRuntime().csrfSigningKey?.trim();
250
+ if (configured) {
251
+ return configured;
252
+ }
253
+ throw new Error("[@holo-js/security] CSRF signing key is not configured.");
254
+ }
255
+ function signCsrfNonce(nonce) {
256
+ return createHmac("sha256", getCsrfSigningKey()).update(nonce).digest("base64url");
257
+ }
258
+ function encodeCsrfToken(nonce) {
259
+ return `${nonce}.${signCsrfNonce(nonce)}`;
260
+ }
261
+ function decodeCsrfToken(token2) {
262
+ const separator = token2.lastIndexOf(".");
263
+ if (separator <= 0 || separator === token2.length - 1) {
264
+ return null;
265
+ }
266
+ return Object.freeze({
267
+ nonce: token2.slice(0, separator),
268
+ signature: token2.slice(separator + 1)
269
+ });
270
+ }
271
+ function isValidSignedCsrfToken(token2) {
272
+ const decoded = decodeCsrfToken(token2);
273
+ if (!decoded) {
274
+ return false;
275
+ }
276
+ const expected = Buffer.from(signCsrfNonce(decoded.nonce));
277
+ const received = Buffer.from(decoded.signature);
278
+ if (expected.length !== received.length) {
279
+ return false;
280
+ }
281
+ return timingSafeEqual(expected, received);
282
+ }
283
+ function getCookieToken(request) {
284
+ const { cookie: cookie2 } = getSecurityRuntime().config.csrf;
285
+ return parseCookieHeader(request.headers.get("cookie"))[cookie2];
286
+ }
287
+ function getHeaderToken(request) {
288
+ const { header } = getSecurityRuntime().config.csrf;
289
+ return request.headers.get(header)?.trim() || void 0;
290
+ }
291
+ function getRequestOrigin(request) {
292
+ const origin = request.headers.get("origin")?.trim();
293
+ if (origin) {
294
+ return origin;
295
+ }
296
+ const referer = request.headers.get("referer")?.trim();
297
+ if (!referer) {
298
+ return void 0;
299
+ }
300
+ try {
301
+ return new URL(referer).origin;
302
+ } catch {
303
+ return void 0;
304
+ }
305
+ }
306
+ function isSameOriginRequest(request) {
307
+ const requestOrigin = getRequestOrigin(request);
308
+ if (!requestOrigin) {
309
+ return false;
310
+ }
311
+ return requestOrigin === new URL(request.url).origin;
312
+ }
313
+ async function readFormToken(request) {
314
+ const { field: field2 } = getSecurityRuntime().config.csrf;
315
+ try {
316
+ const formData = await request.clone().formData();
317
+ const value = formData.get(field2);
318
+ return typeof value === "string" ? value : void 0;
319
+ } catch {
320
+ return void 0;
321
+ }
322
+ }
323
+ async function verifyRequest(request, options) {
324
+ if (isSafeMethod(request.method)) {
325
+ return;
326
+ }
327
+ if (options.allowExcludedPath && isExcludedPath(request)) {
328
+ return;
329
+ }
330
+ const cookieToken = getCookieToken(request);
331
+ if (!cookieToken || !isValidSignedCsrfToken(cookieToken)) {
332
+ throw new SecurityCsrfError();
333
+ }
334
+ const headerToken = getHeaderToken(request);
335
+ if (headerToken) {
336
+ if (headerToken !== cookieToken) {
337
+ throw new SecurityCsrfError();
338
+ }
339
+ return;
340
+ }
341
+ if (isSameOriginRequest(request)) {
342
+ return;
343
+ }
344
+ const requestToken = await readFormToken(request);
345
+ if (requestToken !== cookieToken) {
346
+ throw new SecurityCsrfError();
347
+ }
348
+ }
349
+ function resolveShouldProtect(request, options = {}) {
350
+ if (options.csrf === false) {
351
+ return false;
352
+ }
353
+ if (isSafeMethod(request.method)) {
354
+ return false;
355
+ }
356
+ if (options.csrf === true) {
357
+ return true;
358
+ }
359
+ const { enabled } = getSecurityRuntime().config.csrf;
360
+ if (!enabled) {
361
+ return false;
362
+ }
363
+ if (isExcludedPath(request)) {
364
+ return false;
365
+ }
366
+ return true;
367
+ }
368
+ async function token(request) {
369
+ const cookieToken = getCookieToken(request);
370
+ if (cookieToken && isValidSignedCsrfToken(cookieToken)) {
371
+ return cookieToken;
372
+ }
373
+ const cached = generatedTokenCache.get(request);
374
+ if (cached) {
375
+ return cached;
376
+ }
377
+ const created = encodeCsrfToken(createCsrfToken());
378
+ generatedTokenCache.set(request, created);
379
+ return created;
380
+ }
381
+ async function field(request) {
382
+ const config = getSecurityRuntime().config.csrf;
383
+ return Object.freeze({
384
+ name: config.field,
385
+ value: await token(request)
386
+ });
387
+ }
388
+ async function input(request) {
389
+ const csrfField = await field(request);
390
+ return Object.freeze({
391
+ type: "hidden",
392
+ name: csrfField.name,
393
+ value: csrfField.value
394
+ });
395
+ }
396
+ async function cookie(request, explicitToken) {
397
+ const config = getSecurityRuntime().config.csrf;
398
+ const value = explicitToken ? isValidSignedCsrfToken(explicitToken) ? explicitToken : encodeCsrfToken(explicitToken) : await token(request);
399
+ return serializeCookie(config.cookie, value, {
400
+ secure: isSecureRequest(request)
401
+ });
402
+ }
403
+ async function verify(request) {
404
+ await verifyRequest(request, { allowExcludedPath: true });
405
+ }
406
+ async function protect(request, options = {}) {
407
+ if (typeof options.throttle === "string") {
408
+ await rateLimit(options.throttle, { request });
409
+ }
410
+ if (resolveShouldProtect(request, options)) {
411
+ await verifyRequest(request, {
412
+ allowExcludedPath: options.csrf !== true
413
+ });
414
+ }
415
+ }
416
+ var csrf = Object.freeze({
417
+ token,
418
+ field,
419
+ input,
420
+ cookie,
421
+ verify
422
+ });
423
+ var csrfInternals = {
424
+ createCsrfToken,
425
+ generatedTokenCache,
426
+ getForwardedProto,
427
+ getCookieToken,
428
+ getHeaderToken,
429
+ isSecureRequest,
430
+ isSameOriginRequest,
431
+ readFormToken,
432
+ isExcludedPath,
433
+ isSafeMethod,
434
+ matchesPathPattern,
435
+ parseCookieHeader,
436
+ serializeCookie,
437
+ decodeCsrfToken,
438
+ encodeCsrfToken,
439
+ getCsrfSigningKey,
440
+ isValidSignedCsrfToken
441
+ };
442
+
443
+ // src/cors.ts
444
+ function matchesPathPattern2(pathname, pattern) {
445
+ const segments = pattern.split("*");
446
+ if (segments.length === 1) {
447
+ return pathname === pattern;
448
+ }
449
+ const firstSegment = segments[0];
450
+ if (firstSegment && !pathname.startsWith(firstSegment)) {
451
+ return false;
452
+ }
453
+ let position = firstSegment.length;
454
+ for (let index = 1; index < segments.length; index += 1) {
455
+ const segment = segments[index];
456
+ if (!segment) {
457
+ continue;
458
+ }
459
+ const nextPosition = pathname.indexOf(segment, position);
460
+ if (nextPosition < 0) {
461
+ return false;
462
+ }
463
+ position = nextPosition + segment.length;
464
+ }
465
+ const lastSegment = segments[segments.length - 1];
466
+ return pattern.endsWith("*") || pathname.endsWith(lastSegment);
467
+ }
468
+ function normalizeDomain(value) {
469
+ const trimmed = value.trim();
470
+ if (!trimmed) {
471
+ return "";
472
+ }
473
+ try {
474
+ const parsed = new URL(trimmed);
475
+ return parsed.host.toLowerCase();
476
+ } catch {
477
+ return trimmed.replace(/^\/+|\/+$/g, "").toLowerCase();
478
+ }
479
+ }
480
+ function isCorsPath(config, request) {
481
+ const pathname = new URL(request.url).pathname;
482
+ return config.paths.some((pattern) => matchesPathPattern2(pathname, pattern));
483
+ }
484
+ function isStatefulOrigin(config, origin) {
485
+ const normalizedOrigin = normalizeDomain(origin);
486
+ return normalizedOrigin !== "" && config.statefulDomains.some((domain) => normalizeDomain(domain) === normalizedOrigin);
487
+ }
488
+ function resolveAllowedOrigin(config, origin) {
489
+ if (!origin) {
490
+ return void 0;
491
+ }
492
+ if (config.origins.includes(origin) || isStatefulOrigin(config, origin)) {
493
+ return origin;
494
+ }
495
+ if (config.origins.includes("*")) {
496
+ return config.credentials ? origin : "*";
497
+ }
498
+ return void 0;
499
+ }
500
+ function appendVary(headers2, value) {
501
+ const existing = headers2.get("vary");
502
+ const entries = new Set([
503
+ ...existing ? existing.split(",") : [],
504
+ ...value.split(",")
505
+ ].map((entry) => entry.trim()).filter(Boolean));
506
+ headers2.set("Vary", Array.from(entries).join(", "));
507
+ }
508
+ function headers(request) {
509
+ const config = getSecurityRuntime().cors;
510
+ const result = new Headers();
511
+ if (!isCorsPath(config, request)) {
512
+ return result;
513
+ }
514
+ const origin = request.headers.get("origin");
515
+ if (origin === null) {
516
+ appendVary(result, "Origin");
517
+ return result;
518
+ }
519
+ const allowedOrigin = resolveAllowedOrigin(config, origin);
520
+ if (!allowedOrigin) {
521
+ appendVary(result, "Origin");
522
+ return result;
523
+ }
524
+ result.set("Access-Control-Allow-Origin", allowedOrigin);
525
+ appendVary(result, "Origin");
526
+ if (config.credentials || isStatefulOrigin(config, origin)) {
527
+ result.set("Access-Control-Allow-Credentials", "true");
528
+ }
529
+ if (request.method.toUpperCase() === "OPTIONS") {
530
+ result.set("Access-Control-Allow-Methods", config.methods.join(", "));
531
+ result.set("Access-Control-Allow-Headers", config.headers.join(", "));
532
+ result.set("Access-Control-Max-Age", String(config.maxAge));
533
+ appendVary(result, "Access-Control-Request-Method");
534
+ appendVary(result, "Access-Control-Request-Headers");
535
+ }
536
+ return result;
537
+ }
538
+ function apply(request, response = new Response(null, { status: 204 })) {
539
+ const nextHeaders = new Headers(response.headers);
540
+ const corsHeaders = headers(request);
541
+ corsHeaders.forEach((value, key) => {
542
+ if (key.toLowerCase() === "vary") {
543
+ appendVary(nextHeaders, value);
544
+ return;
545
+ }
546
+ nextHeaders.set(key, value);
547
+ });
548
+ return new Response(response.body, {
549
+ status: response.status,
550
+ statusText: response.statusText,
551
+ headers: nextHeaders
552
+ });
553
+ }
554
+ function preflight(request) {
555
+ if (request.method.toUpperCase() !== "OPTIONS") {
556
+ return null;
557
+ }
558
+ if (!request.headers.has("access-control-request-method")) {
559
+ return null;
560
+ }
561
+ const response = apply(request);
562
+ return response.headers.has("access-control-allow-origin") ? response : null;
563
+ }
564
+ var cors = Object.freeze({
565
+ headers,
566
+ preflight,
567
+ apply
568
+ });
569
+ var corsInternals = {
570
+ appendVary,
571
+ isCorsPath,
572
+ isStatefulOrigin,
573
+ matchesPathPattern: matchesPathPattern2,
574
+ normalizeDomain,
575
+ resolveAllowedOrigin
576
+ };
577
+
578
+ // src/store.ts
579
+ import { resolve } from "path";
580
+ import { normalizeSecurityConfig as normalizeSecurityConfig2 } from "@holo-js/config";
581
+
582
+ // src/drivers/file.ts
583
+ import { createHash, randomUUID } from "crypto";
584
+ import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
585
+ import { dirname, join } from "path";
586
+ var LOCK_OWNER_FILE = "owner";
587
+ var BUCKETS_DIRECTORY = "buckets";
588
+ function createBucketHash(key) {
589
+ return createHash("sha256").update(key).digest("hex");
590
+ }
591
+ function createBucketNamespace(key) {
592
+ const separatorIndex = key.indexOf("|");
593
+ return separatorIndex >= 0 ? key.slice(0, separatorIndex + 1) : key;
594
+ }
595
+ function createBucketNamespaceHash(key) {
596
+ return createBucketHash(createBucketNamespace(key));
597
+ }
598
+ function createBucketPrefixHashes(key) {
599
+ const prefixHashes = [];
600
+ for (let index = 1; index <= key.length; index += 1) {
601
+ prefixHashes.push(createBucketHash(key.slice(0, index)));
602
+ }
603
+ return prefixHashes;
604
+ }
605
+ function getBucketPath(root, key) {
606
+ const hash = createBucketHash(key);
607
+ return join(root, BUCKETS_DIRECTORY, hash.slice(0, 2), hash.slice(2, 4), `${hash}.json`);
608
+ }
609
+ function serializeBucket(bucket) {
610
+ return JSON.stringify({
611
+ namespaceHash: bucket.namespaceHash,
612
+ keyHash: bucket.keyHash,
613
+ prefixHashes: [...bucket.prefixHashes],
614
+ attempts: bucket.attempts,
615
+ expiresAt: bucket.expiresAt.toISOString()
616
+ });
617
+ }
618
+ function deserializeBucket(raw) {
619
+ const parsed = JSON.parse(raw);
620
+ const namespaceHash = typeof parsed.namespaceHash === "string" && parsed.namespaceHash.length > 0 ? parsed.namespaceHash : typeof parsed.namespace === "string" && parsed.namespace.length > 0 ? createBucketHash(parsed.namespace) : void 0;
621
+ const keyHash = typeof parsed.keyHash === "string" && parsed.keyHash.length > 0 ? parsed.keyHash : void 0;
622
+ const prefixHashes = Array.isArray(parsed.prefixHashes) && parsed.prefixHashes.every(
623
+ (value) => typeof value === "string" && value.length > 0
624
+ ) ? parsed.prefixHashes : void 0;
625
+ const { attempts, expiresAt: expiresAtValue } = parsed;
626
+ if (typeof namespaceHash !== "string" || namespaceHash.length === 0) {
627
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain a non-empty string namespace hash.");
628
+ }
629
+ if (typeof keyHash !== "string" || keyHash.length === 0) {
630
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain a non-empty string key hash.");
631
+ }
632
+ if (!Array.isArray(prefixHashes) || prefixHashes.length === 0) {
633
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain non-empty prefix hashes.");
634
+ }
635
+ if (typeof attempts !== "number" || !Number.isInteger(attempts) || attempts < 1) {
636
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain an integer attempts count greater than 0.");
637
+ }
638
+ const normalizedAttempts = attempts;
639
+ if (typeof expiresAtValue !== "string") {
640
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain an ISO expiry timestamp.");
641
+ }
642
+ const expiresAt = new Date(expiresAtValue);
643
+ if (Number.isNaN(expiresAt.getTime())) {
644
+ throw new TypeError("[@holo-js/security] File rate-limit buckets must contain a valid expiry timestamp.");
645
+ }
646
+ return Object.freeze({
647
+ namespaceHash,
648
+ keyHash,
649
+ prefixHashes: Object.freeze([...prefixHashes]),
650
+ attempts: normalizedAttempts,
651
+ expiresAt
652
+ });
653
+ }
654
+ async function readBucket(path) {
655
+ const raw = await readFile(path, "utf8").catch((error) => {
656
+ if (error.code === "ENOENT") {
657
+ return void 0;
658
+ }
659
+ throw error;
660
+ });
661
+ return raw ? deserializeBucket(raw) : null;
662
+ }
663
+ function isExpired(bucket, now) {
664
+ return bucket.expiresAt.getTime() <= now.getTime();
665
+ }
666
+ async function writeBucket(path, bucket) {
667
+ await mkdir(dirname(path), { recursive: true });
668
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
669
+ await writeFile(tempPath, serializeBucket(bucket), "utf8");
670
+ await rename(tempPath, path);
671
+ }
672
+ async function deleteBucket(path) {
673
+ await rm(path, { force: true });
674
+ }
675
+ function getBucketLockPath(path) {
676
+ return `${path}.lock`;
677
+ }
678
+ function getBucketLockCleanupPath(lockPath) {
679
+ return `${lockPath}.cleanup`;
680
+ }
681
+ function getBucketLockOwnerPath(lockPath) {
682
+ return join(lockPath, LOCK_OWNER_FILE);
683
+ }
684
+ async function sleep(delayMs) {
685
+ await new Promise((resolve2) => {
686
+ setTimeout(resolve2, delayMs);
687
+ });
688
+ }
689
+ function startLockHeartbeat(lockPath, timeoutMs) {
690
+ const heartbeat = setInterval(() => {
691
+ const now = /* @__PURE__ */ new Date();
692
+ void utimes(lockPath, now, now).catch(() => {
693
+ });
694
+ }, Math.max(1, Math.floor(timeoutMs / 2)));
695
+ heartbeat.unref?.();
696
+ return heartbeat;
697
+ }
698
+ async function tryCreateBucketLock(lockPath) {
699
+ const ownerId = `${process.pid}:${randomUUID()}`;
700
+ await mkdir(lockPath);
701
+ try {
702
+ await writeFile(getBucketLockOwnerPath(lockPath), ownerId, "utf8");
703
+ } catch (error) {
704
+ await rm(lockPath, { recursive: true, force: true });
705
+ throw error;
706
+ }
707
+ return { ownerId, path: lockPath };
708
+ }
709
+ async function removeOwnedBucketLock(lock) {
710
+ const ownerId = await readFile(getBucketLockOwnerPath(lock.path), "utf8").catch((error) => {
711
+ if (error.code === "ENOENT") {
712
+ return void 0;
713
+ }
714
+ throw error;
715
+ });
716
+ if (ownerId === lock.ownerId) {
717
+ await rm(lock.path, { recursive: true, force: true });
718
+ }
719
+ }
720
+ async function isBucketLockStale(lockPath, timeoutMs) {
721
+ return await stat(lockPath).then((stats) => stats.mtimeMs <= Date.now() - timeoutMs).catch(() => false);
722
+ }
723
+ async function reclaimStaleBucketLock(lockPath, timeoutMs) {
724
+ const cleanupPath = getBucketLockCleanupPath(lockPath);
725
+ let cleanupLock;
726
+ try {
727
+ cleanupLock = await tryCreateBucketLock(cleanupPath);
728
+ } catch (error) {
729
+ const candidate = error;
730
+ if (candidate.code === "EEXIST") {
731
+ return false;
732
+ }
733
+ throw error;
734
+ }
735
+ try {
736
+ if (await isBucketLockStale(lockPath, timeoutMs)) {
737
+ await rm(lockPath, { recursive: true, force: true });
738
+ return true;
739
+ }
740
+ return false;
741
+ } finally {
742
+ await removeOwnedBucketLock(cleanupLock);
743
+ }
744
+ }
745
+ async function acquireBucketLock(lockPath, options) {
746
+ const deadline = Date.now() + options.timeoutMs;
747
+ while (true) {
748
+ try {
749
+ return await tryCreateBucketLock(lockPath);
750
+ } catch (error) {
751
+ const candidate = error;
752
+ if (candidate.code !== "EEXIST") {
753
+ throw error;
754
+ }
755
+ if (await isBucketLockStale(lockPath, options.timeoutMs) && await reclaimStaleBucketLock(lockPath, options.timeoutMs)) {
756
+ continue;
757
+ }
758
+ if (Date.now() >= deadline) {
759
+ throw new Error(`[@holo-js/security] Timed out waiting for file rate-limit lock "${lockPath}".`);
760
+ }
761
+ await sleep(options.retryDelayMs);
762
+ }
763
+ }
764
+ }
765
+ async function withBucketLock(path, options, operation) {
766
+ const lockPath = getBucketLockPath(path);
767
+ await mkdir(dirname(lockPath), { recursive: true });
768
+ const acquiredLock = await acquireBucketLock(lockPath, options);
769
+ const heartbeat = startLockHeartbeat(lockPath, options.timeoutMs);
770
+ try {
771
+ return await operation();
772
+ } finally {
773
+ clearInterval(heartbeat);
774
+ await removeOwnedBucketLock(acquiredLock);
775
+ }
776
+ }
777
+ async function listBucketPaths(root) {
778
+ const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
779
+ if (error.code === "ENOENT") {
780
+ return [];
781
+ }
782
+ throw error;
783
+ });
784
+ const nested = await Promise.all(entries.map(async (entry) => {
785
+ const entryPath = join(root, entry.name);
786
+ if (entry.isDirectory()) {
787
+ return await listBucketPaths(entryPath);
788
+ }
789
+ return entry.name.endsWith(".json") ? [entryPath] : [];
790
+ }));
791
+ return nested.flat();
792
+ }
793
+ function createSnapshot(key, bucket, maxAttempts) {
794
+ const expiresAt = new Date(bucket.expiresAt.getTime());
795
+ return Object.freeze({
796
+ limiter: "",
797
+ key,
798
+ attempts: bucket.attempts,
799
+ maxAttempts,
800
+ remainingAttempts: Math.max(0, maxAttempts - bucket.attempts),
801
+ expiresAt
802
+ });
803
+ }
804
+ function createFileRateLimitStore(root, options = {}) {
805
+ const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
806
+ const lockRetryDelayMs = options.lockRetryDelayMs ?? 5;
807
+ const lockTimeoutMs = options.lockTimeoutMs ?? 2e3;
808
+ return {
809
+ async hit(key, hitOptions) {
810
+ const now = resolveNow();
811
+ const path = getBucketPath(root, key);
812
+ return await withBucketLock(path, {
813
+ retryDelayMs: lockRetryDelayMs,
814
+ timeoutMs: lockTimeoutMs
815
+ }, async () => {
816
+ const existing = await readBucket(path);
817
+ if (existing && existing.keyHash !== createBucketHash(key)) {
818
+ throw new Error(`[@holo-js/security] File rate-limit bucket hash collision detected for stored bucket ${existing.keyHash}.`);
819
+ }
820
+ if (existing && isExpired(existing, now)) {
821
+ await deleteBucket(path);
822
+ }
823
+ const bucket = existing && !isExpired(existing, now) ? {
824
+ ...existing,
825
+ attempts: existing.attempts + 1
826
+ } : {
827
+ namespaceHash: createBucketNamespaceHash(key),
828
+ keyHash: createBucketHash(key),
829
+ prefixHashes: Object.freeze(createBucketPrefixHashes(key)),
830
+ attempts: 1,
831
+ expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
832
+ };
833
+ await writeBucket(path, bucket);
834
+ return Object.freeze({
835
+ limited: bucket.attempts > hitOptions.maxAttempts,
836
+ snapshot: createSnapshot(key, bucket, hitOptions.maxAttempts),
837
+ retryAfterSeconds: Math.max(0, Math.ceil((bucket.expiresAt.getTime() - now.getTime()) / 1e3))
838
+ });
839
+ });
840
+ },
841
+ async clear(key) {
842
+ const path = getBucketPath(root, key);
843
+ return await withBucketLock(path, {
844
+ retryDelayMs: lockRetryDelayMs,
845
+ timeoutMs: lockTimeoutMs
846
+ }, async () => {
847
+ const existing = await readBucket(path);
848
+ if (!existing || existing.keyHash !== createBucketHash(key)) {
849
+ return false;
850
+ }
851
+ await deleteBucket(path);
852
+ return true;
853
+ });
854
+ },
855
+ async clearByPrefix(prefix) {
856
+ let cleared = 0;
857
+ const now = resolveNow();
858
+ const paths = await listBucketPaths(root);
859
+ for (const path of paths) {
860
+ const removed = await withBucketLock(path, {
861
+ retryDelayMs: lockRetryDelayMs,
862
+ timeoutMs: lockTimeoutMs
863
+ }, async () => {
864
+ const bucket = await readBucket(path);
865
+ if (!bucket) {
866
+ return false;
867
+ }
868
+ if (isExpired(bucket, now)) {
869
+ await deleteBucket(path);
870
+ return false;
871
+ }
872
+ if (!bucket.prefixHashes.includes(createBucketHash(prefix))) {
873
+ return false;
874
+ }
875
+ await deleteBucket(path);
876
+ return true;
877
+ });
878
+ if (removed) {
879
+ cleared += 1;
880
+ }
881
+ }
882
+ return cleared;
883
+ },
884
+ async clearAll() {
885
+ const paths = await listBucketPaths(root);
886
+ let cleared = 0;
887
+ for (const path of paths) {
888
+ const removed = await withBucketLock(path, {
889
+ retryDelayMs: lockRetryDelayMs,
890
+ timeoutMs: lockTimeoutMs
891
+ }, async () => {
892
+ const bucket = await readBucket(path);
893
+ if (!bucket) {
894
+ return false;
895
+ }
896
+ await deleteBucket(path);
897
+ return true;
898
+ });
899
+ if (removed) {
900
+ cleared += 1;
901
+ }
902
+ }
903
+ return cleared;
904
+ }
905
+ };
906
+ }
907
+ var fileRateLimitDriverInternals = {
908
+ createBucketHash,
909
+ createSnapshot,
910
+ deleteBucket,
911
+ deserializeBucket,
912
+ getBucketPath,
913
+ isExpired,
914
+ listBucketPaths,
915
+ readBucket,
916
+ serializeBucket,
917
+ sleep,
918
+ getBucketLockPath,
919
+ acquireBucketLock,
920
+ reclaimStaleBucketLock,
921
+ removeOwnedBucketLock,
922
+ withBucketLock,
923
+ writeBucket
924
+ };
925
+
926
+ // src/drivers/memory.ts
927
+ function createSnapshot2(key, bucket, maxAttempts) {
928
+ const expiresAt = new Date(bucket.expiresAt.getTime());
929
+ return Object.freeze({
930
+ limiter: "",
931
+ key,
932
+ attempts: bucket.attempts,
933
+ maxAttempts,
934
+ remainingAttempts: Math.max(0, maxAttempts - bucket.attempts),
935
+ expiresAt
936
+ });
937
+ }
938
+ function isExpired2(bucket, now) {
939
+ return bucket.expiresAt.getTime() <= now.getTime();
940
+ }
941
+ function createMemoryRateLimitStore(options = {}) {
942
+ const buckets = /* @__PURE__ */ new Map();
943
+ const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
944
+ const maxBuckets = options.maxBuckets;
945
+ if (typeof maxBuckets !== "undefined" && (!Number.isInteger(maxBuckets) || maxBuckets < 1)) {
946
+ throw new TypeError("[@holo-js/security] Memory rate-limit store maxBuckets must be an integer greater than or equal to 1.");
947
+ }
948
+ const pruneIntervalMs = typeof options.pruneIntervalMs === "number" ? options.pruneIntervalMs : 6e4;
949
+ if (!Number.isInteger(pruneIntervalMs) || pruneIntervalMs < 1) {
950
+ throw new TypeError("[@holo-js/security] Memory rate-limit store pruneIntervalMs must be an integer greater than or equal to 1.");
951
+ }
952
+ const pruneExpiredBuckets = () => {
953
+ const now = resolveNow();
954
+ for (const [key, bucket] of buckets) {
955
+ if (isExpired2(bucket, now)) {
956
+ buckets.delete(key);
957
+ }
958
+ }
959
+ };
960
+ const pruneTimer = setInterval(pruneExpiredBuckets, pruneIntervalMs);
961
+ pruneTimer.unref?.();
962
+ const evictOldestBucket = () => {
963
+ const oldestKey = buckets.keys().next().value;
964
+ buckets.delete(oldestKey);
965
+ };
966
+ return {
967
+ async hit(key, hitOptions) {
968
+ const now = resolveNow();
969
+ const existing = buckets.get(key);
970
+ if (existing && isExpired2(existing, now)) {
971
+ buckets.delete(key);
972
+ }
973
+ const active = buckets.get(key);
974
+ const bucket = active ? {
975
+ ...active,
976
+ attempts: active.attempts + 1
977
+ } : {
978
+ key,
979
+ attempts: 1,
980
+ expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
981
+ };
982
+ if (!active && typeof maxBuckets === "number") {
983
+ while (buckets.size >= maxBuckets) {
984
+ evictOldestBucket();
985
+ }
986
+ }
987
+ if (active) {
988
+ buckets.delete(key);
989
+ }
990
+ buckets.set(key, bucket);
991
+ return Object.freeze({
992
+ limited: bucket.attempts > hitOptions.maxAttempts,
993
+ snapshot: createSnapshot2(key, bucket, hitOptions.maxAttempts),
994
+ retryAfterSeconds: Math.max(0, Math.ceil((bucket.expiresAt.getTime() - now.getTime()) / 1e3))
995
+ });
996
+ },
997
+ async clear(key) {
998
+ return buckets.delete(key);
999
+ },
1000
+ async clearByPrefix(prefix) {
1001
+ let cleared = 0;
1002
+ for (const key of buckets.keys()) {
1003
+ if (!key.startsWith(prefix)) {
1004
+ continue;
1005
+ }
1006
+ buckets.delete(key);
1007
+ cleared += 1;
1008
+ }
1009
+ return cleared;
1010
+ },
1011
+ async clearAll() {
1012
+ const cleared = buckets.size;
1013
+ buckets.clear();
1014
+ return cleared;
1015
+ },
1016
+ async close() {
1017
+ clearInterval(pruneTimer);
1018
+ }
1019
+ };
1020
+ }
1021
+ var memoryRateLimitDriverInternals = {
1022
+ createSnapshot: createSnapshot2,
1023
+ isExpired: isExpired2
1024
+ };
1025
+
1026
+ // src/drivers/redis.ts
1027
+ function assertNonNegativeInteger(value, label) {
1028
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1029
+ throw new TypeError(`[@holo-js/security] Redis rate-limit adapter ${label} must be a non-negative integer.`);
1030
+ }
1031
+ return value;
1032
+ }
1033
+ function createSnapshot3(key, attempts, maxAttempts, expiresAt) {
1034
+ return Object.freeze({
1035
+ limiter: "",
1036
+ key,
1037
+ attempts,
1038
+ maxAttempts,
1039
+ remainingAttempts: Math.max(0, maxAttempts - attempts),
1040
+ expiresAt
1041
+ });
1042
+ }
1043
+ function createRedisRateLimitStore(adapter, options = {}) {
1044
+ const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
1045
+ return {
1046
+ async hit(key, hitOptions) {
1047
+ const result = await adapter.increment(key, {
1048
+ decaySeconds: hitOptions.decaySeconds
1049
+ });
1050
+ const attempts = assertNonNegativeInteger(result.attempts, "attempts");
1051
+ const ttlSeconds = assertNonNegativeInteger(result.ttlSeconds, "ttlSeconds");
1052
+ const now = resolveNow();
1053
+ const expiresAt = new Date(now.getTime() + ttlSeconds * 1e3);
1054
+ return Object.freeze({
1055
+ limited: attempts > hitOptions.maxAttempts,
1056
+ snapshot: createSnapshot3(key, attempts, hitOptions.maxAttempts, expiresAt),
1057
+ retryAfterSeconds: ttlSeconds
1058
+ });
1059
+ },
1060
+ async clear(key) {
1061
+ const deleted = await adapter.del(key);
1062
+ return assertNonNegativeInteger(deleted, "del() result") > 0;
1063
+ },
1064
+ async clearByPrefix(prefix) {
1065
+ if (typeof adapter.clearByPrefix !== "function") {
1066
+ return 0;
1067
+ }
1068
+ const cleared = await adapter.clearByPrefix(prefix);
1069
+ return assertNonNegativeInteger(cleared, "clearByPrefix() result");
1070
+ },
1071
+ async clearAll() {
1072
+ if (typeof adapter.clearAll !== "function") {
1073
+ return 0;
1074
+ }
1075
+ const cleared = await adapter.clearAll();
1076
+ return assertNonNegativeInteger(cleared, "clearAll() result");
1077
+ },
1078
+ async close() {
1079
+ await adapter.close?.();
1080
+ }
1081
+ };
1082
+ }
1083
+ var redisRateLimitDriverInternals = {
1084
+ assertNonNegativeInteger,
1085
+ createSnapshot: createSnapshot3
1086
+ };
1087
+
1088
+ // src/store.ts
1089
+ function isNormalizedSecurityConfig(config) {
1090
+ const candidate = config;
1091
+ return typeof candidate.rateLimit?.memory?.driver === "string" && candidate.rateLimit.memory.driver === "memory" && typeof candidate.rateLimit.file?.path === "string" && typeof candidate.rateLimit.redis?.host === "string" && typeof candidate.rateLimit.redis.port === "number" && typeof candidate.rateLimit.redis.db === "number" && typeof candidate.rateLimit.redis.connection === "string" && typeof candidate.rateLimit.redis.prefix === "string" && typeof candidate.rateLimit.limiters === "object";
1092
+ }
1093
+ function normalizeStoreConfig(config) {
1094
+ return isNormalizedSecurityConfig(config) ? config : normalizeSecurityConfig2(config);
1095
+ }
1096
+ function createRateLimitStoreFromConfig(config, options = {}) {
1097
+ const normalized = normalizeStoreConfig(config);
1098
+ switch (normalized.rateLimit.driver) {
1099
+ case "memory":
1100
+ return createMemoryRateLimitStore();
1101
+ case "file": {
1102
+ const root = options.projectRoot ? resolve(options.projectRoot, normalized.rateLimit.file.path) : normalized.rateLimit.file.path;
1103
+ return createFileRateLimitStore(root);
1104
+ }
1105
+ case "redis":
1106
+ if (!options.redisAdapter) {
1107
+ throw new Error("[@holo-js/security] Redis-backed rate limits require a redis adapter.");
1108
+ }
1109
+ return createRedisRateLimitStore(options.redisAdapter);
1110
+ default:
1111
+ throw new Error(`[@holo-js/security] Unsupported rate limit driver "${normalized.rateLimit.driver}".`);
1112
+ }
1113
+ }
1114
+ var securityStoreInternals = {
1115
+ normalizeStoreConfig
1116
+ };
1117
+
1118
+ // src/index.ts
1119
+ import { defineSecurityConfig } from "@holo-js/config";
1120
+ var security = Object.freeze({
1121
+ configureSecurityRuntime,
1122
+ getSecurityRuntime,
1123
+ getSecurityRuntimeBindings,
1124
+ resetSecurityRuntime,
1125
+ csrf,
1126
+ cors,
1127
+ protect,
1128
+ defaultRateLimitKey,
1129
+ rateLimit,
1130
+ clearRateLimit,
1131
+ limit,
1132
+ ip
1133
+ });
1134
+ var src_default = security;
1135
+
1136
+ export {
1137
+ SecurityRuntimeNotConfiguredError,
1138
+ configureSecurityRuntime,
1139
+ getSecurityRuntime,
1140
+ getSecurityRuntimeBindings,
1141
+ resetSecurityRuntime,
1142
+ securityRuntimeInternals,
1143
+ defaultRateLimitKey,
1144
+ rateLimit,
1145
+ clearRateLimit,
1146
+ rateLimitInternals,
1147
+ isSecureRequest,
1148
+ token,
1149
+ field,
1150
+ input,
1151
+ cookie,
1152
+ verify,
1153
+ protect,
1154
+ csrf,
1155
+ csrfInternals,
1156
+ headers,
1157
+ apply,
1158
+ preflight,
1159
+ cors,
1160
+ corsInternals,
1161
+ createFileRateLimitStore,
1162
+ fileRateLimitDriverInternals,
1163
+ createMemoryRateLimitStore,
1164
+ memoryRateLimitDriverInternals,
1165
+ createRedisRateLimitStore,
1166
+ redisRateLimitDriverInternals,
1167
+ createRateLimitStoreFromConfig,
1168
+ securityStoreInternals,
1169
+ src_default,
1170
+ defineSecurityConfig
1171
+ };