@holo-js/security 0.3.27 → 0.4.0

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.
@@ -1,18 +1,24 @@
1
+ import {
2
+ SECURITY_XSRF_HEADER
3
+ } from "./chunk-EINZXLC2.mjs";
1
4
  import {
2
5
  SecurityCsrfError,
3
6
  SecurityRateLimitError,
4
- ip,
5
7
  limit
6
- } from "./chunk-FQ4X7X6L.mjs";
8
+ } from "./chunk-U25HSHLJ.mjs";
7
9
  import {
10
+ createForwardedRequestResolver,
8
11
  normalizeCorsConfig,
9
- normalizeSecurityConfig
10
- } from "./chunk-WRMZRO77.mjs";
12
+ normalizeSecurityConfig,
13
+ normalizeTrustedProxies,
14
+ resolveForwarding
15
+ } from "./chunk-D3ZN5MLJ.mjs";
11
16
 
12
- // src/csrf.ts
13
- import { createHmac, randomBytes, timingSafeEqual } from "crypto";
17
+ // src/proxy.ts
18
+ import { requestUrlInternals as requestUrlInternals2 } from "@holo-js/kernel";
14
19
 
15
20
  // src/runtime.ts
21
+ import { requestBodyInternals, requestUrlInternals } from "@holo-js/kernel";
16
22
  function getSecurityRuntimeState() {
17
23
  const runtime = globalThis;
18
24
  runtime.__holoSecurityRuntime__ ??= {};
@@ -24,11 +30,16 @@ var SecurityRuntimeNotConfiguredError = class extends Error {
24
30
  }
25
31
  };
26
32
  function configureSecurityRuntime(bindings) {
27
- getSecurityRuntimeState().bindings = bindings ? Object.freeze({
28
- config: normalizeSecurityConfig(bindings.config),
33
+ const config = bindings ? normalizeSecurityConfig(bindings.config) : void 0;
34
+ requestUrlInternals.configureForwardedRequestResolver(config ? createForwardedRequestResolver(config.trustedProxies) : void 0);
35
+ requestBodyInternals.configureMaxRequestBodyBytes(config?.maxBodyBytes);
36
+ requestBodyInternals.configureMaxUploadBodyBytes(config?.maxUploadBodyBytes);
37
+ getSecurityRuntimeState().bindings = bindings && config ? Object.freeze({
38
+ config,
29
39
  cors: normalizeCorsConfig(bindings.cors),
30
40
  rateLimitStore: bindings.rateLimitStore,
31
41
  csrfSigningKey: bindings.csrfSigningKey,
42
+ csrfSession: bindings.csrfSession,
32
43
  defaultKeyResolver: bindings.defaultKeyResolver
33
44
  }) : void 0;
34
45
  }
@@ -43,6 +54,9 @@ function getSecurityRuntimeBindings() {
43
54
  return getSecurityRuntimeState().bindings;
44
55
  }
45
56
  function resetSecurityRuntime() {
57
+ requestUrlInternals.configureForwardedRequestResolver(void 0);
58
+ requestBodyInternals.configureMaxRequestBodyBytes(void 0);
59
+ requestBodyInternals.configureMaxUploadBodyBytes(void 0);
46
60
  getSecurityRuntimeState().bindings = void 0;
47
61
  }
48
62
  var securityRuntimeInternals = {
@@ -50,11 +64,27 @@ var securityRuntimeInternals = {
50
64
  };
51
65
 
52
66
  // src/proxy.ts
53
- function shouldTrustProxyHeaders() {
54
- const trustedProxy = typeof process !== "undefined" ? process.env.HOLO_SECURITY_TRUST_PROXY?.trim().toLowerCase() : void 0;
55
- return trustedProxy === "1" || trustedProxy === "true" || trustedProxy === "yes" || trustedProxy === "on";
67
+ var SecurityClientAddressUnavailableError = class extends Error {
68
+ constructor() {
69
+ super("[@holo-js/security] The client address is unavailable because this framework does not expose the connection address, so an address or CIDR list in trustedProxies can never match. Only '*' works here, and only behind a proxy that overwrites X-Forwarded-For, such as Vercel's edge, or a reverse proxy with the Next.js server bound to localhost or a private network. Set trustedProxies in config/security.ts to '*' (scaffolded apps read it from TRUSTED_PROXIES). If the app doesn't need IP identity, pass an explicit rate-limit key instead. See https://docs.holo-js.com/basics/security/cors-and-proxies#trusted-proxies");
70
+ this.name = "SecurityClientAddressUnavailableError";
71
+ }
72
+ };
73
+ function ip(request) {
74
+ const trustedProxies = getSecurityRuntimeBindings()?.config.trustedProxies ?? normalizeTrustedProxies(void 0);
75
+ const { address } = resolveForwarding(
76
+ (name) => request.headers.get(name) ?? void 0,
77
+ requestUrlInternals2.currentConnectionAddress(),
78
+ trustedProxies
79
+ );
80
+ if (!address) throw new SecurityClientAddressUnavailableError();
81
+ return address;
56
82
  }
57
83
 
84
+ // src/csrf.ts
85
+ import { cookieEncryptionInternals, HoloRequestBodyTooLargeError, readBoundedRequestBody, resolveRequestUrl } from "@holo-js/kernel";
86
+ import { randomBytes, timingSafeEqual } from "crypto";
87
+
58
88
  // src/rate-limit.ts
59
89
  function encodeBucketPart(value) {
60
90
  return encodeURIComponent(value);
@@ -93,7 +123,7 @@ async function defaultRateLimitKey(request) {
93
123
  if (typeof runtimeDefaultKey !== "undefined" && runtimeDefaultKey !== null) {
94
124
  return normalizeResolvedLimiterKey(runtimeDefaultKey, "Default rate limiter key resolver");
95
125
  }
96
- return `ip:${ip(request, shouldTrustProxyHeaders())}`;
126
+ return `ip:${ip(request)}`;
97
127
  }
98
128
  async function resolveLimiterKey(name, limiter, options) {
99
129
  if (typeof options.key === "string" && options.key.length > 0) {
@@ -171,32 +201,8 @@ var rateLimitInternals = {
171
201
  };
172
202
 
173
203
  // src/csrf.ts
174
- var generatedTokenCache = /* @__PURE__ */ new WeakMap();
175
- function parseCookieHeader(header) {
176
- if (!header) {
177
- return Object.freeze({});
178
- }
179
- const decodeCookiePart = (value) => {
180
- try {
181
- return decodeURIComponent(value);
182
- } catch {
183
- return void 0;
184
- }
185
- };
186
- const entries = header.split(";").map((segment) => segment.trim()).filter(Boolean).map((segment) => {
187
- const separator = segment.indexOf("=");
188
- if (separator <= 0) {
189
- return void 0;
190
- }
191
- const key = decodeCookiePart(segment.slice(0, separator));
192
- const value = decodeCookiePart(segment.slice(separator + 1));
193
- if (!key || typeof value === "undefined") {
194
- return void 0;
195
- }
196
- return [key, value];
197
- }).filter((entry) => !!entry);
198
- return Object.freeze(Object.fromEntries(entries));
199
- }
204
+ var requestTokenCache = /* @__PURE__ */ new WeakMap();
205
+ var persistedTokens = /* @__PURE__ */ new WeakMap();
200
206
  function serializeCookie(name, value, options = {}) {
201
207
  const attributes = [
202
208
  `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
@@ -224,85 +230,90 @@ function isExcludedPath(request) {
224
230
  const pathname = new URL(request.url).pathname;
225
231
  return pathname === "/broadcasting/auth" || except.some((pattern) => matchesPathPattern(pathname, pattern));
226
232
  }
227
- function normalizeForwardedValue(value) {
228
- return value.trim().replace(/^"|"$/g, "").toLowerCase();
229
- }
230
- function getForwardedProto(request) {
231
- const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",", 1)[0]?.trim();
232
- if (forwardedProto) {
233
- return normalizeForwardedValue(forwardedProto);
234
- }
235
- const forwarded = request.headers.get("forwarded")?.split(",", 1)[0];
236
- if (!forwarded) {
237
- return void 0;
238
- }
239
- for (const segment of forwarded.split(";")) {
240
- const [name, value] = segment.split("=", 2);
241
- if (name?.trim().toLowerCase() === "proto" && value) {
242
- return normalizeForwardedValue(value);
243
- }
244
- }
245
- return void 0;
246
- }
247
233
  function isSecureRequest(request) {
248
- return new URL(request.url).protocol === "https:" || shouldTrustProxyHeaders() && getForwardedProto(request) === "https";
234
+ return resolveRequestUrl(request).protocol === "https:";
249
235
  }
250
236
  function createCsrfToken() {
251
237
  return randomBytes(32).toString("base64url");
252
238
  }
253
- function getCsrfSigningKey() {
254
- const configured = getSecurityRuntime().csrfSigningKey?.trim();
255
- if (configured) {
256
- return configured;
257
- }
258
- throw new Error("[@holo-js/security] CSRF signing key is not configured.");
239
+ function csrfCookieName() {
240
+ return getSecurityRuntime().config.csrf.cookie;
259
241
  }
260
- function signCsrfNonce(nonce) {
261
- return createHmac("sha256", getCsrfSigningKey()).update(nonce).digest("base64url");
242
+ function encryptCsrfToken(token2) {
243
+ return cookieEncryptionInternals.encryptCookieValue(csrfCookieName(), token2, getSecurityRuntime().csrfSigningKey);
262
244
  }
263
- function encodeCsrfToken(nonce) {
264
- return `${nonce}.${signCsrfNonce(nonce)}`;
245
+ function decryptCsrfToken(value) {
246
+ return cookieEncryptionInternals.decryptCookieValue(csrfCookieName(), value, getSecurityRuntime().csrfSigningKey);
265
247
  }
266
- function decodeCsrfToken(token2) {
267
- const separator = token2.lastIndexOf(".");
268
- if (separator <= 0 || separator === token2.length - 1) {
269
- return null;
248
+ function tokensMatch(expected, submitted) {
249
+ if (!submitted) {
250
+ return false;
270
251
  }
271
- return Object.freeze({
272
- nonce: token2.slice(0, separator),
273
- signature: token2.slice(separator + 1)
274
- });
252
+ const expectedBytes = Buffer.from(expected);
253
+ const submittedBytes = Buffer.from(submitted);
254
+ return expectedBytes.length === submittedBytes.length && timingSafeEqual(expectedBytes, submittedBytes);
275
255
  }
276
- function isValidSignedCsrfToken(token2) {
277
- const decoded = decodeCsrfToken(token2);
278
- if (!decoded) {
279
- return false;
256
+ function csrfSession() {
257
+ const session = getSecurityRuntime().csrfSession;
258
+ if (!session) {
259
+ throw new Error("[@holo-js/security] CSRF protection requires a session. Install and configure @holo-js/session.");
280
260
  }
281
- const expected = Buffer.from(signCsrfNonce(decoded.nonce));
282
- const received = Buffer.from(decoded.signature);
283
- if (expected.length !== received.length) {
284
- return false;
261
+ return session;
262
+ }
263
+ async function readSessionToken(request) {
264
+ const stored = await csrfSession().read(request);
265
+ if (stored) {
266
+ persistedTokens.set(request, stored);
285
267
  }
286
- return timingSafeEqual(expected, received);
268
+ return stored;
287
269
  }
288
- function getCookieToken(request) {
289
- const { cookie: cookie2 } = getSecurityRuntime().config.csrf;
290
- return parseCookieHeader(request.headers.get("cookie"))[cookie2];
270
+ async function getSessionToken(request) {
271
+ return persistedTokens.get(request) ?? requestTokenCache.get(request) ?? await readSessionToken(request);
291
272
  }
292
273
  function getHeaderToken(request) {
293
274
  const { header } = getSecurityRuntime().config.csrf;
275
+ if (header.toLowerCase() === SECURITY_XSRF_HEADER.toLowerCase()) {
276
+ return void 0;
277
+ }
294
278
  return request.headers.get(header)?.trim() || void 0;
295
279
  }
296
- async function readFormToken(request) {
297
- const { field: field2 } = getSecurityRuntime().config.csrf;
280
+ function getXsrfHeaderToken(request) {
281
+ const value = request.headers.get(SECURITY_XSRF_HEADER)?.trim();
282
+ return value ? decryptCsrfToken(value) : void 0;
283
+ }
284
+ function nonEmptyString(value) {
285
+ return typeof value === "string" && value ? value : void 0;
286
+ }
287
+ async function readBodyBytes(request) {
298
288
  try {
299
- const formData = await request.clone().formData();
300
- const value = formData.get(field2);
301
- return typeof value === "string" ? value : void 0;
289
+ return await readBoundedRequestBody(request.clone());
290
+ } catch (error) {
291
+ if (error instanceof HoloRequestBodyTooLargeError) throw error;
292
+ return void 0;
293
+ }
294
+ }
295
+ async function readBodyToken(request, field2) {
296
+ const contentType = request.headers.get("content-type") ?? "";
297
+ const bytes = await readBodyBytes(request);
298
+ if (!bytes) return void 0;
299
+ try {
300
+ if (/[/+]json\b/i.test(contentType)) {
301
+ const body = JSON.parse(new TextDecoder().decode(bytes));
302
+ return body && typeof body === "object" && !Array.isArray(body) && Object.hasOwn(body, field2) ? nonEmptyString(body[field2]) : void 0;
303
+ }
304
+ return nonEmptyString((await new Response(bytes, { headers: { "content-type": contentType } }).formData()).get(field2));
302
305
  } catch {
303
306
  return void 0;
304
307
  }
305
308
  }
309
+ async function readInputToken(request) {
310
+ const { field: field2 } = getSecurityRuntime().config.csrf;
311
+ return await readBodyToken(request, field2) ?? nonEmptyString(new URL(request.url).searchParams.get(field2));
312
+ }
313
+ async function getSubmittedToken(request) {
314
+ const token2 = await readInputToken(request) ?? getHeaderToken(request);
315
+ return token2 ? decryptCsrfToken(token2) ?? token2 : getXsrfHeaderToken(request);
316
+ }
306
317
  async function verifyRequest(request, options) {
307
318
  if (isSafeMethod(request.method)) {
308
319
  return;
@@ -310,21 +321,21 @@ async function verifyRequest(request, options) {
310
321
  if (options.allowExcludedPath && isExcludedPath(request)) {
311
322
  return;
312
323
  }
313
- const cookieToken = getCookieToken(request);
314
- if (!cookieToken || !isValidSignedCsrfToken(cookieToken)) {
324
+ const sessionToken = await getSessionToken(request);
325
+ if (!sessionToken || !tokensMatch(sessionToken, await getSubmittedToken(request))) {
315
326
  throw new SecurityCsrfError();
316
327
  }
317
- const headerToken = getHeaderToken(request);
318
- if (headerToken) {
319
- if (headerToken !== cookieToken) {
320
- throw new SecurityCsrfError();
321
- }
322
- return;
323
- }
324
- const requestToken = await readFormToken(request);
325
- if (requestToken !== cookieToken) {
326
- throw new SecurityCsrfError();
328
+ }
329
+ async function ensureSessionToken(request) {
330
+ const stored = persistedTokens.get(request) ?? await readSessionToken(request);
331
+ if (stored) {
332
+ return { token: stored, sessionCookies: [] };
327
333
  }
334
+ const token2 = requestTokenCache.get(request) ?? createCsrfToken();
335
+ const written = await csrfSession().write(request, token2);
336
+ requestTokenCache.set(request, written.token);
337
+ persistedTokens.set(request, written.token);
338
+ return { token: written.token, sessionCookies: written.cookies };
328
339
  }
329
340
  function resolveShouldProtect(request, options = {}) {
330
341
  if (options.csrf === false) {
@@ -346,16 +357,12 @@ function resolveShouldProtect(request, options = {}) {
346
357
  return true;
347
358
  }
348
359
  async function token(request) {
349
- const cookieToken = getCookieToken(request);
350
- if (cookieToken && isValidSignedCsrfToken(cookieToken)) {
351
- return cookieToken;
352
- }
353
- const cached = generatedTokenCache.get(request);
354
- if (cached) {
355
- return cached;
360
+ const sessionToken = await getSessionToken(request);
361
+ if (sessionToken) {
362
+ return sessionToken;
356
363
  }
357
- const created = encodeCsrfToken(createCsrfToken());
358
- generatedTokenCache.set(request, created);
364
+ const created = createCsrfToken();
365
+ requestTokenCache.set(request, created);
359
366
  return created;
360
367
  }
361
368
  async function field(request) {
@@ -373,10 +380,11 @@ async function input(request) {
373
380
  value: csrfField.value
374
381
  });
375
382
  }
383
+ async function encryptedToken(request, explicitToken) {
384
+ return encryptCsrfToken(explicitToken || await token(request));
385
+ }
376
386
  async function cookie(request, explicitToken) {
377
- const config = getSecurityRuntime().config.csrf;
378
- const value = explicitToken ? isValidSignedCsrfToken(explicitToken) ? explicitToken : encodeCsrfToken(explicitToken) : await token(request);
379
- return serializeCookie(config.cookie, value, {
387
+ return serializeCookie(csrfCookieName(), await encryptedToken(request, explicitToken), {
380
388
  secure: isSecureRequest(request)
381
389
  });
382
390
  }
@@ -400,23 +408,24 @@ var csrf = Object.freeze({
400
408
  cookie,
401
409
  verify
402
410
  });
411
+ function rememberSessionToken(request, token2) {
412
+ persistedTokens.set(request, token2);
413
+ }
403
414
  var csrfInternals = {
404
415
  createCsrfToken,
405
- generatedTokenCache,
406
- getForwardedProto,
407
- getCookieToken,
416
+ rememberSessionToken,
417
+ ensureSessionToken,
418
+ requestTokenCache,
408
419
  getHeaderToken,
420
+ getXsrfHeaderToken,
409
421
  isSecureRequest,
410
- readFormToken,
422
+ readInputToken,
411
423
  isExcludedPath,
412
424
  isSafeMethod,
413
425
  matchesPathPattern,
414
- parseCookieHeader,
415
426
  serializeCookie,
416
- decodeCsrfToken,
417
- encodeCsrfToken,
418
- getCsrfSigningKey,
419
- isValidSignedCsrfToken
427
+ decryptCsrfToken,
428
+ encryptCsrfToken
420
429
  };
421
430
 
422
431
  // src/cors.ts
@@ -472,7 +481,7 @@ function resolveAllowedOrigin(config, origin) {
472
481
  return origin;
473
482
  }
474
483
  if (config.origins.includes("*")) {
475
- return config.credentials ? origin : "*";
484
+ return "*";
476
485
  }
477
486
  return void 0;
478
487
  }
@@ -559,30 +568,37 @@ import { resolve } from "path";
559
568
 
560
569
  // src/drivers/file.ts
561
570
  import { createHash, randomUUID } from "crypto";
562
- import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
571
+ import { mkdir, opendir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "fs/promises";
563
572
  import { dirname, join } from "path";
573
+ import { reportError } from "@holo-js/kernel/error-reporting";
564
574
  var LOCK_OWNER_FILE = "owner";
565
575
  var BUCKETS_DIRECTORY = "buckets";
576
+ var PRUNE_INTERVAL_MS = 6e4;
577
+ var SWEEP_BATCH_SIZE = 1e3;
566
578
  function createBucketHash(key) {
567
579
  return createHash("sha256").update(key).digest("hex");
568
580
  }
569
- function createBucketNamespace(key) {
570
- const separatorIndex = key.indexOf("|");
571
- return separatorIndex >= 0 ? key.slice(0, separatorIndex + 1) : key;
572
- }
573
- function createBucketNamespaceHash(key) {
574
- return createBucketHash(createBucketNamespace(key));
575
- }
576
- function createBucketPrefixHashes(key) {
581
+ function hashBucketKey(key, keyHash) {
582
+ const hash = createHash("sha256");
577
583
  const prefixHashes = [];
578
- for (let index = 1; index <= key.length; index += 1) {
579
- prefixHashes.push(createBucketHash(key.slice(0, index)));
584
+ let namespaceHash;
585
+ let prefixLength = 0;
586
+ for (const character of key) {
587
+ hash.update(character);
588
+ prefixLength += character.length;
589
+ const prefixHash = prefixLength === key.length ? keyHash : hash.copy().digest("hex");
590
+ prefixHashes.push(prefixHash);
591
+ if (namespaceHash === void 0 && character === "|") {
592
+ namespaceHash = prefixHash;
593
+ }
580
594
  }
581
- return prefixHashes;
595
+ return Object.freeze({
596
+ namespaceHash: namespaceHash ?? keyHash,
597
+ prefixHashes: Object.freeze(prefixHashes)
598
+ });
582
599
  }
583
- function getBucketPath(root, key) {
584
- const hash = createBucketHash(key);
585
- return join(root, BUCKETS_DIRECTORY, hash.slice(0, 2), hash.slice(2, 4), `${hash}.json`);
600
+ function getBucketPathForKeyHash(root, keyHash) {
601
+ return join(root, BUCKETS_DIRECTORY, keyHash.slice(0, 2), keyHash.slice(2, 4), `${keyHash}.json`);
586
602
  }
587
603
  function serializeBucket(bucket) {
588
604
  return JSON.stringify({
@@ -734,12 +750,42 @@ async function acquireBucketLock(lockPath, options) {
734
750
  continue;
735
751
  }
736
752
  if (Date.now() >= deadline) {
737
- throw new Error(`[@holo-js/security] Timed out waiting for file rate-limit lock "${lockPath}".`);
753
+ throw new Error(`[@holo-js/security] Timed out waiting for file rate-limit lock "${lockPath}".`, { cause: error });
738
754
  }
739
755
  await sleep(options.retryDelayMs);
740
756
  }
741
757
  }
742
758
  }
759
+ async function withBucketLockIfFree(path, options, operation) {
760
+ const lockPath = getBucketLockPath(path);
761
+ await mkdir(dirname(lockPath), { recursive: true });
762
+ let lock;
763
+ try {
764
+ lock = await tryCreateBucketLock(lockPath);
765
+ } catch (error) {
766
+ if (error.code !== "EEXIST") {
767
+ throw error;
768
+ }
769
+ if (!await isBucketLockStale(lockPath, options.timeoutMs) || !await reclaimStaleBucketLock(lockPath, options.timeoutMs)) {
770
+ return void 0;
771
+ }
772
+ try {
773
+ lock = await tryCreateBucketLock(lockPath);
774
+ } catch (retryError) {
775
+ if (retryError.code === "EEXIST") {
776
+ return void 0;
777
+ }
778
+ throw retryError;
779
+ }
780
+ }
781
+ const heartbeat = startLockHeartbeat(lockPath, options.timeoutMs);
782
+ try {
783
+ return await operation();
784
+ } finally {
785
+ clearInterval(heartbeat);
786
+ await removeOwnedBucketLock(lock);
787
+ }
788
+ }
743
789
  async function withBucketLock(path, options, operation) {
744
790
  const lockPath = getBucketLockPath(path);
745
791
  await mkdir(dirname(lockPath), { recursive: true });
@@ -752,21 +798,92 @@ async function withBucketLock(path, options, operation) {
752
798
  await removeOwnedBucketLock(acquiredLock);
753
799
  }
754
800
  }
755
- async function listBucketPaths(root) {
756
- const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
801
+ var UNIT_STEP = { kind: "unit" };
802
+ function* chargeUnits(count) {
803
+ for (let index = 0; index < count; index += 1) {
804
+ yield UNIT_STEP;
805
+ }
806
+ }
807
+ async function readShardEntries(directory, onUnreadable) {
808
+ try {
809
+ return await readdir(directory, { withFileTypes: true });
810
+ } catch (error) {
757
811
  if (error.code === "ENOENT") {
758
- return [];
812
+ return void 0;
759
813
  }
760
- throw error;
761
- });
762
- const nested = await Promise.all(entries.map(async (entry) => {
763
- const entryPath = join(root, entry.name);
764
- if (entry.isDirectory()) {
765
- return await listBucketPaths(entryPath);
814
+ if (!onUnreadable) {
815
+ throw error;
816
+ }
817
+ onUnreadable(directory, error);
818
+ return void 0;
819
+ }
820
+ }
821
+ async function* readShardListing(directory, onUnreadable) {
822
+ yield UNIT_STEP;
823
+ const entries = await readShardEntries(directory, onUnreadable);
824
+ yield* chargeUnits(entries?.length ?? 0);
825
+ yield UNIT_STEP;
826
+ if (!entries) {
827
+ return void 0;
828
+ }
829
+ return {
830
+ names: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
831
+ };
832
+ }
833
+ async function openShard(directory, onUnreadable) {
834
+ try {
835
+ return await opendir(directory);
836
+ } catch (error) {
837
+ if (error.code === "ENOENT") {
838
+ return void 0;
839
+ }
840
+ if (!onUnreadable) {
841
+ throw error;
842
+ }
843
+ onUnreadable(directory, error);
844
+ return void 0;
845
+ }
846
+ }
847
+ async function readShardEntry(handle, directory, onUnreadable) {
848
+ try {
849
+ return await handle.read();
850
+ } catch (error) {
851
+ if (!onUnreadable) {
852
+ throw error;
853
+ }
854
+ onUnreadable(directory, error);
855
+ return null;
856
+ }
857
+ }
858
+ async function* walkBucketTree(bucketsRoot, onUnreadable) {
859
+ const tops = yield* readShardListing(bucketsRoot, onUnreadable);
860
+ for (const top of tops?.names ?? []) {
861
+ const topPath = join(bucketsRoot, top);
862
+ const middles = yield* readShardListing(topPath, onUnreadable);
863
+ for (const middle of middles?.names ?? []) {
864
+ const leaf = join(topPath, middle);
865
+ yield UNIT_STEP;
866
+ const handle = await openShard(leaf, onUnreadable);
867
+ if (!handle) {
868
+ continue;
869
+ }
870
+ try {
871
+ for (let entry = await readShardEntry(handle, leaf, onUnreadable); entry !== null; entry = await readShardEntry(handle, leaf, onUnreadable)) {
872
+ yield entry.isFile() && entry.name.endsWith(".json") ? { kind: "bucket", path: join(leaf, entry.name) } : UNIT_STEP;
873
+ }
874
+ } finally {
875
+ await handle.close().catch(() => void 0);
876
+ }
877
+ yield UNIT_STEP;
766
878
  }
767
- return entry.name.endsWith(".json") ? [entryPath] : [];
768
- }));
769
- return nested.flat();
879
+ }
880
+ }
881
+ async function* bucketFilePaths(bucketsRoot) {
882
+ for await (const step of walkBucketTree(bucketsRoot)) {
883
+ if (step.kind === "bucket") {
884
+ yield step.path;
885
+ }
886
+ }
770
887
  }
771
888
  function createSnapshot(key, bucket, maxAttempts) {
772
889
  const expiresAt = new Date(bucket.expiresAt.getTime());
@@ -783,16 +900,96 @@ function createFileRateLimitStore(root, options = {}) {
783
900
  const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
784
901
  const lockRetryDelayMs = options.lockRetryDelayMs ?? 5;
785
902
  const lockTimeoutMs = options.lockTimeoutMs ?? 2e3;
786
- return {
903
+ const lockOptions = {
904
+ retryDelayMs: lockRetryDelayMs,
905
+ timeoutMs: lockTimeoutMs
906
+ };
907
+ const bucketsRoot = join(root, BUCKETS_DIRECTORY);
908
+ let cycleStartedAt;
909
+ let cursor;
910
+ let inFlight;
911
+ let closed = false;
912
+ const reportSweepError = (error) => {
913
+ reportError(error, { capability: "security", operation: "rate-limit expired-bucket sweep" });
914
+ };
915
+ const reportedUnreadable = /* @__PURE__ */ new Set();
916
+ const reportUnreadable = (directory, error) => {
917
+ if (reportedUnreadable.has(directory)) {
918
+ return;
919
+ }
920
+ reportedUnreadable.add(directory);
921
+ reportSweepError(error);
922
+ };
923
+ const sweepBucket = async (path, now) => {
924
+ try {
925
+ const candidate = await readBucket(path);
926
+ if (!candidate || !isExpired(candidate, now)) {
927
+ return;
928
+ }
929
+ await withBucketLockIfFree(path, { timeoutMs: lockTimeoutMs }, async () => {
930
+ const bucket = await readBucket(path);
931
+ if (bucket && isExpired(bucket, now)) {
932
+ await deleteBucket(path);
933
+ }
934
+ });
935
+ } catch (error) {
936
+ reportSweepError(error);
937
+ }
938
+ };
939
+ const closeCursor = async () => {
940
+ const current = cursor;
941
+ cursor = void 0;
942
+ await current?.return(void 0);
943
+ };
944
+ const runSweep = async (now) => {
945
+ let units = 0;
946
+ let finished = false;
947
+ try {
948
+ cursor ??= walkBucketTree(bucketsRoot, reportUnreadable);
949
+ while (units < SWEEP_BATCH_SIZE && !closed) {
950
+ const next = await cursor.next();
951
+ if (next.done) {
952
+ finished = true;
953
+ break;
954
+ }
955
+ units += 1;
956
+ if (next.value.kind === "bucket") {
957
+ await sweepBucket(next.value.path, now);
958
+ }
959
+ }
960
+ } catch (error) {
961
+ reportSweepError(error);
962
+ finished = true;
963
+ } finally {
964
+ if (finished || closed) {
965
+ await closeCursor();
966
+ }
967
+ }
968
+ };
969
+ const startSweepIfDue = (now) => {
970
+ if (closed || inFlight !== void 0) {
971
+ return;
972
+ }
973
+ if (cursor === void 0) {
974
+ if (cycleStartedAt !== void 0 && now.getTime() - cycleStartedAt < PRUNE_INTERVAL_MS) {
975
+ return;
976
+ }
977
+ cycleStartedAt = now.getTime();
978
+ }
979
+ inFlight = runSweep(now).finally(() => {
980
+ inFlight = void 0;
981
+ });
982
+ };
983
+ const store = {
787
984
  async hit(key, hitOptions) {
788
- const now = resolveNow();
789
- const path = getBucketPath(root, key);
790
- return await withBucketLock(path, {
791
- retryDelayMs: lockRetryDelayMs,
792
- timeoutMs: lockTimeoutMs
793
- }, async () => {
985
+ const sweepNow = resolveNow();
986
+ const keyHash = createBucketHash(key);
987
+ const path = getBucketPathForKeyHash(root, keyHash);
988
+ startSweepIfDue(sweepNow);
989
+ return await withBucketLock(path, lockOptions, async () => {
794
990
  const existing = await readBucket(path);
795
- if (existing && existing.keyHash !== createBucketHash(key)) {
991
+ const now = resolveNow();
992
+ if (existing && existing.keyHash !== keyHash) {
796
993
  throw new Error(`[@holo-js/security] File rate-limit bucket hash collision detected for stored bucket ${existing.keyHash}.`);
797
994
  }
798
995
  if (existing && isExpired(existing, now)) {
@@ -802,9 +999,8 @@ function createFileRateLimitStore(root, options = {}) {
802
999
  ...existing,
803
1000
  attempts: existing.attempts + 1
804
1001
  } : {
805
- namespaceHash: createBucketNamespaceHash(key),
806
- keyHash: createBucketHash(key),
807
- prefixHashes: Object.freeze(createBucketPrefixHashes(key)),
1002
+ ...hashBucketKey(key, keyHash),
1003
+ keyHash,
808
1004
  attempts: 1,
809
1005
  expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
810
1006
  };
@@ -817,13 +1013,11 @@ function createFileRateLimitStore(root, options = {}) {
817
1013
  });
818
1014
  },
819
1015
  async clear(key) {
820
- const path = getBucketPath(root, key);
821
- return await withBucketLock(path, {
822
- retryDelayMs: lockRetryDelayMs,
823
- timeoutMs: lockTimeoutMs
824
- }, async () => {
1016
+ const keyHash = createBucketHash(key);
1017
+ const path = getBucketPathForKeyHash(root, keyHash);
1018
+ return await withBucketLock(path, lockOptions, async () => {
825
1019
  const existing = await readBucket(path);
826
- if (!existing || existing.keyHash !== createBucketHash(key)) {
1020
+ if (!existing || existing.keyHash !== keyHash) {
827
1021
  return false;
828
1022
  }
829
1023
  await deleteBucket(path);
@@ -833,12 +1027,9 @@ function createFileRateLimitStore(root, options = {}) {
833
1027
  async clearByPrefix(prefix) {
834
1028
  let cleared = 0;
835
1029
  const now = resolveNow();
836
- const paths = await listBucketPaths(root);
837
- for (const path of paths) {
838
- const removed = await withBucketLock(path, {
839
- retryDelayMs: lockRetryDelayMs,
840
- timeoutMs: lockTimeoutMs
841
- }, async () => {
1030
+ const prefixHash = createBucketHash(prefix);
1031
+ for await (const path of bucketFilePaths(bucketsRoot)) {
1032
+ const removed = await withBucketLock(path, lockOptions, async () => {
842
1033
  const bucket = await readBucket(path);
843
1034
  if (!bucket) {
844
1035
  return false;
@@ -847,7 +1038,7 @@ function createFileRateLimitStore(root, options = {}) {
847
1038
  await deleteBucket(path);
848
1039
  return false;
849
1040
  }
850
- if (!bucket.prefixHashes.includes(createBucketHash(prefix))) {
1041
+ if (!bucket.prefixHashes.includes(prefixHash)) {
851
1042
  return false;
852
1043
  }
853
1044
  await deleteBucket(path);
@@ -860,13 +1051,9 @@ function createFileRateLimitStore(root, options = {}) {
860
1051
  return cleared;
861
1052
  },
862
1053
  async clearAll() {
863
- const paths = await listBucketPaths(root);
864
1054
  let cleared = 0;
865
- for (const path of paths) {
866
- const removed = await withBucketLock(path, {
867
- retryDelayMs: lockRetryDelayMs,
868
- timeoutMs: lockTimeoutMs
869
- }, async () => {
1055
+ for await (const path of bucketFilePaths(bucketsRoot)) {
1056
+ const removed = await withBucketLock(path, lockOptions, async () => {
870
1057
  const bucket = await readBucket(path);
871
1058
  if (!bucket) {
872
1059
  return false;
@@ -879,29 +1066,19 @@ function createFileRateLimitStore(root, options = {}) {
879
1066
  }
880
1067
  }
881
1068
  return cleared;
1069
+ },
1070
+ async close() {
1071
+ closed = true;
1072
+ await inFlight;
1073
+ await closeCursor();
882
1074
  }
883
1075
  };
1076
+ return store;
884
1077
  }
885
- var fileRateLimitDriverInternals = {
886
- createBucketHash,
887
- createSnapshot,
888
- deleteBucket,
889
- deserializeBucket,
890
- getBucketPath,
891
- isExpired,
892
- listBucketPaths,
893
- readBucket,
894
- serializeBucket,
895
- sleep,
896
- getBucketLockPath,
897
- acquireBucketLock,
898
- reclaimStaleBucketLock,
899
- removeOwnedBucketLock,
900
- withBucketLock,
901
- writeBucket
902
- };
903
1078
 
904
1079
  // src/drivers/memory.ts
1080
+ import { reportError as reportError2 } from "@holo-js/kernel/error-reporting";
1081
+ var DEFAULT_MEMORY_MAX_BUCKETS = 1e4;
905
1082
  function createSnapshot2(key, bucket, maxAttempts) {
906
1083
  const expiresAt = new Date(bucket.expiresAt.getTime());
907
1084
  return Object.freeze({
@@ -919,27 +1096,41 @@ function isExpired2(bucket, now) {
919
1096
  function createMemoryRateLimitStore(options = {}) {
920
1097
  const buckets = /* @__PURE__ */ new Map();
921
1098
  const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
922
- const maxBuckets = options.maxBuckets;
923
- if (typeof maxBuckets !== "undefined" && (!Number.isInteger(maxBuckets) || maxBuckets < 1)) {
1099
+ const maxBuckets = options.maxBuckets ?? DEFAULT_MEMORY_MAX_BUCKETS;
1100
+ if (!Number.isInteger(maxBuckets) || maxBuckets < 1) {
924
1101
  throw new TypeError("[@holo-js/security] Memory rate-limit store maxBuckets must be an integer greater than or equal to 1.");
925
1102
  }
926
1103
  const pruneIntervalMs = typeof options.pruneIntervalMs === "number" ? options.pruneIntervalMs : 6e4;
927
1104
  if (!Number.isInteger(pruneIntervalMs) || pruneIntervalMs < 1) {
928
1105
  throw new TypeError("[@holo-js/security] Memory rate-limit store pruneIntervalMs must be an integer greater than or equal to 1.");
929
1106
  }
930
- const pruneExpiredBuckets = () => {
931
- const now = resolveNow();
1107
+ let nextExpiryAt = Number.POSITIVE_INFINITY;
1108
+ const refreshNextExpiry = () => {
1109
+ nextExpiryAt = Number.POSITIVE_INFINITY;
1110
+ for (const bucket of buckets.values()) {
1111
+ nextExpiryAt = Math.min(nextExpiryAt, bucket.expiresAt.getTime());
1112
+ }
1113
+ };
1114
+ const pruneExpiredBuckets = (now) => {
932
1115
  for (const [key, bucket] of buckets) {
933
1116
  if (isExpired2(bucket, now)) {
934
1117
  buckets.delete(key);
935
1118
  }
936
1119
  }
1120
+ refreshNextExpiry();
937
1121
  };
938
- const pruneTimer = setInterval(pruneExpiredBuckets, pruneIntervalMs);
1122
+ const pruneTimer = setInterval(() => pruneExpiredBuckets(resolveNow()), pruneIntervalMs);
939
1123
  pruneTimer.unref?.();
940
- const evictOldestBucket = () => {
941
- const oldestKey = buckets.keys().next().value;
942
- buckets.delete(oldestKey);
1124
+ let capacityReportedFor;
1125
+ const reportCapacity = (freesAt) => {
1126
+ if (capacityReportedFor === freesAt) {
1127
+ return;
1128
+ }
1129
+ capacityReportedFor = freesAt;
1130
+ reportError2(
1131
+ new Error(`[@holo-js/security] Memory rate-limit store is at capacity (${maxBuckets} keys). New keys are limited until buckets expire. Use the redis or file rate-limit driver for high-cardinality limiters.`),
1132
+ { capability: "security", operation: "rate-limit memory store at capacity" }
1133
+ );
943
1134
  };
944
1135
  return {
945
1136
  async hit(key, hitOptions) {
@@ -957,15 +1148,29 @@ function createMemoryRateLimitStore(options = {}) {
957
1148
  attempts: 1,
958
1149
  expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
959
1150
  };
960
- if (!active && typeof maxBuckets === "number") {
961
- while (buckets.size >= maxBuckets) {
962
- evictOldestBucket();
1151
+ if (!active && buckets.size >= maxBuckets) {
1152
+ if (now.getTime() >= nextExpiryAt) {
1153
+ pruneExpiredBuckets(now);
1154
+ }
1155
+ if (buckets.size >= maxBuckets) {
1156
+ const freesAt = nextExpiryAt;
1157
+ reportCapacity(freesAt);
1158
+ return Object.freeze({
1159
+ limited: true,
1160
+ snapshot: createSnapshot2(key, {
1161
+ key,
1162
+ attempts: hitOptions.maxAttempts + 1,
1163
+ expiresAt: new Date(freesAt)
1164
+ }, hitOptions.maxAttempts),
1165
+ retryAfterSeconds: Math.max(0, Math.ceil((freesAt - now.getTime()) / 1e3))
1166
+ });
963
1167
  }
964
1168
  }
965
1169
  if (active) {
966
1170
  buckets.delete(key);
967
1171
  }
968
1172
  buckets.set(key, bucket);
1173
+ nextExpiryAt = Math.min(nextExpiryAt, bucket.expiresAt.getTime());
969
1174
  return Object.freeze({
970
1175
  limited: bucket.attempts > hitOptions.maxAttempts,
971
1176
  snapshot: createSnapshot2(key, bucket, hitOptions.maxAttempts),
@@ -973,7 +1178,9 @@ function createMemoryRateLimitStore(options = {}) {
973
1178
  });
974
1179
  },
975
1180
  async clear(key) {
976
- return buckets.delete(key);
1181
+ const deleted = buckets.delete(key);
1182
+ refreshNextExpiry();
1183
+ return deleted;
977
1184
  },
978
1185
  async clearByPrefix(prefix) {
979
1186
  let cleared = 0;
@@ -984,11 +1191,13 @@ function createMemoryRateLimitStore(options = {}) {
984
1191
  buckets.delete(key);
985
1192
  cleared += 1;
986
1193
  }
1194
+ refreshNextExpiry();
987
1195
  return cleared;
988
1196
  },
989
1197
  async clearAll() {
990
1198
  const cleared = buckets.size;
991
1199
  buckets.clear();
1200
+ refreshNextExpiry();
992
1201
  return cleared;
993
1202
  },
994
1203
  async close() {
@@ -1094,7 +1303,7 @@ var securityStoreInternals = {
1094
1303
  };
1095
1304
 
1096
1305
  // src/signed-tokens.ts
1097
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1306
+ import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
1098
1307
  var MAX_SIGNED_TOKEN_DEPTH = 32;
1099
1308
  var MAX_SIGNED_TOKEN_BODY_BYTES = 64 * 1024;
1100
1309
  var MAX_SIGNED_TOKEN_CHARACTERS = Math.ceil(MAX_SIGNED_TOKEN_BODY_BYTES * 4 / 3) + 44;
@@ -1146,7 +1355,7 @@ function assertPayload(payload) {
1146
1355
  }
1147
1356
  }
1148
1357
  function signature(body, purpose) {
1149
- return createHmac2("sha256", signingKey()).update("holo-signed-token-v1\0").update(purpose).update("\0").update(body).digest();
1358
+ return createHmac("sha256", signingKey()).update("holo-signed-token-v1\0").update(purpose).update("\0").update(body).digest();
1150
1359
  }
1151
1360
  function decodeEnvelope(body) {
1152
1361
  try {
@@ -1222,6 +1431,7 @@ export {
1222
1431
  getSecurityRuntimeBindings,
1223
1432
  resetSecurityRuntime,
1224
1433
  securityRuntimeInternals,
1434
+ ip,
1225
1435
  defaultRateLimitKey,
1226
1436
  rateLimit,
1227
1437
  clearRateLimit,
@@ -1230,6 +1440,7 @@ export {
1230
1440
  token,
1231
1441
  field,
1232
1442
  input,
1443
+ encryptedToken,
1233
1444
  cookie,
1234
1445
  verify,
1235
1446
  protect,
@@ -1241,7 +1452,6 @@ export {
1241
1452
  cors,
1242
1453
  corsInternals,
1243
1454
  createFileRateLimitStore,
1244
- fileRateLimitDriverInternals,
1245
1455
  createMemoryRateLimitStore,
1246
1456
  memoryRateLimitDriverInternals,
1247
1457
  createRedisRateLimitStore,