@holo-js/security 0.3.28 → 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,19 +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 { resolveRequestUrl } from "@holo-js/kernel";
14
- import { createHmac, randomBytes, timingSafeEqual } from "crypto";
17
+ // src/proxy.ts
18
+ import { requestUrlInternals as requestUrlInternals2 } from "@holo-js/kernel";
15
19
 
16
20
  // src/runtime.ts
21
+ import { requestBodyInternals, requestUrlInternals } from "@holo-js/kernel";
17
22
  function getSecurityRuntimeState() {
18
23
  const runtime = globalThis;
19
24
  runtime.__holoSecurityRuntime__ ??= {};
@@ -25,11 +30,16 @@ var SecurityRuntimeNotConfiguredError = class extends Error {
25
30
  }
26
31
  };
27
32
  function configureSecurityRuntime(bindings) {
28
- getSecurityRuntimeState().bindings = bindings ? Object.freeze({
29
- 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,
30
39
  cors: normalizeCorsConfig(bindings.cors),
31
40
  rateLimitStore: bindings.rateLimitStore,
32
41
  csrfSigningKey: bindings.csrfSigningKey,
42
+ csrfSession: bindings.csrfSession,
33
43
  defaultKeyResolver: bindings.defaultKeyResolver
34
44
  }) : void 0;
35
45
  }
@@ -44,6 +54,9 @@ function getSecurityRuntimeBindings() {
44
54
  return getSecurityRuntimeState().bindings;
45
55
  }
46
56
  function resetSecurityRuntime() {
57
+ requestUrlInternals.configureForwardedRequestResolver(void 0);
58
+ requestBodyInternals.configureMaxRequestBodyBytes(void 0);
59
+ requestBodyInternals.configureMaxUploadBodyBytes(void 0);
47
60
  getSecurityRuntimeState().bindings = void 0;
48
61
  }
49
62
  var securityRuntimeInternals = {
@@ -51,24 +64,27 @@ var securityRuntimeInternals = {
51
64
  };
52
65
 
53
66
  // src/proxy.ts
54
- import { isIP } from "net";
55
- import { requestUrlInternals } from "@holo-js/kernel";
56
- function bareClientAddress(value) {
57
- if (!value) return void 0;
58
- if (isIP(value)) return value;
59
- const bracketed = /^\[([^\]]+)\](?::\d+)?$/u.exec(value)?.[1];
60
- if (bracketed && isIP(bracketed) === 6) return bracketed;
61
- const ipv4 = /^([\d.]+):\d+$/u.exec(value)?.[1];
62
- return ipv4 && isIP(ipv4) === 4 ? ipv4 : void 0;
63
- }
64
- function resolveTrustedClientIp(request) {
65
- const trustedProxyHeaders = requestUrlInternals.resolveTrustedProxyHeaders();
66
- if (trustedProxyHeaders === "forwarded") {
67
- return bareClientAddress(requestUrlInternals.readForwardedParameter(request.headers, "for")) ?? "unknown";
68
- }
69
- return ip(request, trustedProxyHeaders === "x-forwarded");
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;
70
82
  }
71
83
 
84
+ // src/csrf.ts
85
+ import { cookieEncryptionInternals, HoloRequestBodyTooLargeError, readBoundedRequestBody, resolveRequestUrl } from "@holo-js/kernel";
86
+ import { randomBytes, timingSafeEqual } from "crypto";
87
+
72
88
  // src/rate-limit.ts
73
89
  function encodeBucketPart(value) {
74
90
  return encodeURIComponent(value);
@@ -107,7 +123,7 @@ async function defaultRateLimitKey(request) {
107
123
  if (typeof runtimeDefaultKey !== "undefined" && runtimeDefaultKey !== null) {
108
124
  return normalizeResolvedLimiterKey(runtimeDefaultKey, "Default rate limiter key resolver");
109
125
  }
110
- return `ip:${resolveTrustedClientIp(request)}`;
126
+ return `ip:${ip(request)}`;
111
127
  }
112
128
  async function resolveLimiterKey(name, limiter, options) {
113
129
  if (typeof options.key === "string" && options.key.length > 0) {
@@ -185,32 +201,8 @@ var rateLimitInternals = {
185
201
  };
186
202
 
187
203
  // src/csrf.ts
188
- var generatedTokenCache = /* @__PURE__ */ new WeakMap();
189
- function parseCookieHeader(header) {
190
- if (!header) {
191
- return Object.freeze({});
192
- }
193
- const decodeCookiePart = (value) => {
194
- try {
195
- return decodeURIComponent(value);
196
- } catch {
197
- return void 0;
198
- }
199
- };
200
- const entries = header.split(";").map((segment) => segment.trim()).filter(Boolean).map((segment) => {
201
- const separator = segment.indexOf("=");
202
- if (separator <= 0) {
203
- return void 0;
204
- }
205
- const key = decodeCookiePart(segment.slice(0, separator));
206
- const value = decodeCookiePart(segment.slice(separator + 1));
207
- if (!key || typeof value === "undefined") {
208
- return void 0;
209
- }
210
- return [key, value];
211
- }).filter((entry) => !!entry);
212
- return Object.freeze(Object.fromEntries(entries));
213
- }
204
+ var requestTokenCache = /* @__PURE__ */ new WeakMap();
205
+ var persistedTokens = /* @__PURE__ */ new WeakMap();
214
206
  function serializeCookie(name, value, options = {}) {
215
207
  const attributes = [
216
208
  `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
@@ -244,59 +236,84 @@ function isSecureRequest(request) {
244
236
  function createCsrfToken() {
245
237
  return randomBytes(32).toString("base64url");
246
238
  }
247
- function getCsrfSigningKey() {
248
- const configured = getSecurityRuntime().csrfSigningKey?.trim();
249
- if (configured) {
250
- return configured;
251
- }
252
- throw new Error("[@holo-js/security] CSRF signing key is not configured.");
239
+ function csrfCookieName() {
240
+ return getSecurityRuntime().config.csrf.cookie;
253
241
  }
254
- function signCsrfNonce(nonce) {
255
- return createHmac("sha256", getCsrfSigningKey()).update(nonce).digest("base64url");
242
+ function encryptCsrfToken(token2) {
243
+ return cookieEncryptionInternals.encryptCookieValue(csrfCookieName(), token2, getSecurityRuntime().csrfSigningKey);
256
244
  }
257
- function encodeCsrfToken(nonce) {
258
- return `${nonce}.${signCsrfNonce(nonce)}`;
245
+ function decryptCsrfToken(value) {
246
+ return cookieEncryptionInternals.decryptCookieValue(csrfCookieName(), value, getSecurityRuntime().csrfSigningKey);
259
247
  }
260
- function decodeCsrfToken(token2) {
261
- const separator = token2.lastIndexOf(".");
262
- if (separator <= 0 || separator === token2.length - 1) {
263
- return null;
248
+ function tokensMatch(expected, submitted) {
249
+ if (!submitted) {
250
+ return false;
264
251
  }
265
- return Object.freeze({
266
- nonce: token2.slice(0, separator),
267
- signature: token2.slice(separator + 1)
268
- });
252
+ const expectedBytes = Buffer.from(expected);
253
+ const submittedBytes = Buffer.from(submitted);
254
+ return expectedBytes.length === submittedBytes.length && timingSafeEqual(expectedBytes, submittedBytes);
269
255
  }
270
- function isValidSignedCsrfToken(token2) {
271
- const decoded = decodeCsrfToken(token2);
272
- if (!decoded) {
273
- 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.");
274
260
  }
275
- const expected = Buffer.from(signCsrfNonce(decoded.nonce));
276
- const received = Buffer.from(decoded.signature);
277
- if (expected.length !== received.length) {
278
- 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);
279
267
  }
280
- return timingSafeEqual(expected, received);
268
+ return stored;
281
269
  }
282
- function getCookieToken(request) {
283
- const { cookie: cookie2 } = getSecurityRuntime().config.csrf;
284
- return parseCookieHeader(request.headers.get("cookie"))[cookie2];
270
+ async function getSessionToken(request) {
271
+ return persistedTokens.get(request) ?? requestTokenCache.get(request) ?? await readSessionToken(request);
285
272
  }
286
273
  function getHeaderToken(request) {
287
274
  const { header } = getSecurityRuntime().config.csrf;
275
+ if (header.toLowerCase() === SECURITY_XSRF_HEADER.toLowerCase()) {
276
+ return void 0;
277
+ }
288
278
  return request.headers.get(header)?.trim() || void 0;
289
279
  }
290
- async function readFormToken(request) {
291
- 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) {
292
288
  try {
293
- const formData = await request.clone().formData();
294
- const value = formData.get(field2);
295
- 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));
296
305
  } catch {
297
306
  return void 0;
298
307
  }
299
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
+ }
300
317
  async function verifyRequest(request, options) {
301
318
  if (isSafeMethod(request.method)) {
302
319
  return;
@@ -304,21 +321,21 @@ async function verifyRequest(request, options) {
304
321
  if (options.allowExcludedPath && isExcludedPath(request)) {
305
322
  return;
306
323
  }
307
- const cookieToken = getCookieToken(request);
308
- if (!cookieToken || !isValidSignedCsrfToken(cookieToken)) {
324
+ const sessionToken = await getSessionToken(request);
325
+ if (!sessionToken || !tokensMatch(sessionToken, await getSubmittedToken(request))) {
309
326
  throw new SecurityCsrfError();
310
327
  }
311
- const headerToken = getHeaderToken(request);
312
- if (headerToken) {
313
- if (headerToken !== cookieToken) {
314
- throw new SecurityCsrfError();
315
- }
316
- return;
317
- }
318
- const requestToken = await readFormToken(request);
319
- if (requestToken !== cookieToken) {
320
- 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: [] };
321
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 };
322
339
  }
323
340
  function resolveShouldProtect(request, options = {}) {
324
341
  if (options.csrf === false) {
@@ -340,16 +357,12 @@ function resolveShouldProtect(request, options = {}) {
340
357
  return true;
341
358
  }
342
359
  async function token(request) {
343
- const cookieToken = getCookieToken(request);
344
- if (cookieToken && isValidSignedCsrfToken(cookieToken)) {
345
- return cookieToken;
360
+ const sessionToken = await getSessionToken(request);
361
+ if (sessionToken) {
362
+ return sessionToken;
346
363
  }
347
- const cached = generatedTokenCache.get(request);
348
- if (cached) {
349
- return cached;
350
- }
351
- const created = encodeCsrfToken(createCsrfToken());
352
- generatedTokenCache.set(request, created);
364
+ const created = createCsrfToken();
365
+ requestTokenCache.set(request, created);
353
366
  return created;
354
367
  }
355
368
  async function field(request) {
@@ -367,10 +380,11 @@ async function input(request) {
367
380
  value: csrfField.value
368
381
  });
369
382
  }
383
+ async function encryptedToken(request, explicitToken) {
384
+ return encryptCsrfToken(explicitToken || await token(request));
385
+ }
370
386
  async function cookie(request, explicitToken) {
371
- const config = getSecurityRuntime().config.csrf;
372
- const value = explicitToken ? isValidSignedCsrfToken(explicitToken) ? explicitToken : encodeCsrfToken(explicitToken) : await token(request);
373
- return serializeCookie(config.cookie, value, {
387
+ return serializeCookie(csrfCookieName(), await encryptedToken(request, explicitToken), {
374
388
  secure: isSecureRequest(request)
375
389
  });
376
390
  }
@@ -394,22 +408,24 @@ var csrf = Object.freeze({
394
408
  cookie,
395
409
  verify
396
410
  });
411
+ function rememberSessionToken(request, token2) {
412
+ persistedTokens.set(request, token2);
413
+ }
397
414
  var csrfInternals = {
398
415
  createCsrfToken,
399
- generatedTokenCache,
400
- getCookieToken,
416
+ rememberSessionToken,
417
+ ensureSessionToken,
418
+ requestTokenCache,
401
419
  getHeaderToken,
420
+ getXsrfHeaderToken,
402
421
  isSecureRequest,
403
- readFormToken,
422
+ readInputToken,
404
423
  isExcludedPath,
405
424
  isSafeMethod,
406
425
  matchesPathPattern,
407
- parseCookieHeader,
408
426
  serializeCookie,
409
- decodeCsrfToken,
410
- encodeCsrfToken,
411
- getCsrfSigningKey,
412
- isValidSignedCsrfToken
427
+ decryptCsrfToken,
428
+ encryptCsrfToken
413
429
  };
414
430
 
415
431
  // src/cors.ts
@@ -465,7 +481,7 @@ function resolveAllowedOrigin(config, origin) {
465
481
  return origin;
466
482
  }
467
483
  if (config.origins.includes("*")) {
468
- return config.credentials ? origin : "*";
484
+ return "*";
469
485
  }
470
486
  return void 0;
471
487
  }
@@ -552,30 +568,37 @@ import { resolve } from "path";
552
568
 
553
569
  // src/drivers/file.ts
554
570
  import { createHash, randomUUID } from "crypto";
555
- 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";
556
572
  import { dirname, join } from "path";
573
+ import { reportError } from "@holo-js/kernel/error-reporting";
557
574
  var LOCK_OWNER_FILE = "owner";
558
575
  var BUCKETS_DIRECTORY = "buckets";
576
+ var PRUNE_INTERVAL_MS = 6e4;
577
+ var SWEEP_BATCH_SIZE = 1e3;
559
578
  function createBucketHash(key) {
560
579
  return createHash("sha256").update(key).digest("hex");
561
580
  }
562
- function createBucketNamespace(key) {
563
- const separatorIndex = key.indexOf("|");
564
- return separatorIndex >= 0 ? key.slice(0, separatorIndex + 1) : key;
565
- }
566
- function createBucketNamespaceHash(key) {
567
- return createBucketHash(createBucketNamespace(key));
568
- }
569
- function createBucketPrefixHashes(key) {
581
+ function hashBucketKey(key, keyHash) {
582
+ const hash = createHash("sha256");
570
583
  const prefixHashes = [];
571
- for (let index = 1; index <= key.length; index += 1) {
572
- 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
+ }
573
594
  }
574
- return prefixHashes;
595
+ return Object.freeze({
596
+ namespaceHash: namespaceHash ?? keyHash,
597
+ prefixHashes: Object.freeze(prefixHashes)
598
+ });
575
599
  }
576
- function getBucketPath(root, key) {
577
- const hash = createBucketHash(key);
578
- 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`);
579
602
  }
580
603
  function serializeBucket(bucket) {
581
604
  return JSON.stringify({
@@ -727,12 +750,42 @@ async function acquireBucketLock(lockPath, options) {
727
750
  continue;
728
751
  }
729
752
  if (Date.now() >= deadline) {
730
- 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 });
731
754
  }
732
755
  await sleep(options.retryDelayMs);
733
756
  }
734
757
  }
735
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
+ }
736
789
  async function withBucketLock(path, options, operation) {
737
790
  const lockPath = getBucketLockPath(path);
738
791
  await mkdir(dirname(lockPath), { recursive: true });
@@ -745,21 +798,92 @@ async function withBucketLock(path, options, operation) {
745
798
  await removeOwnedBucketLock(acquiredLock);
746
799
  }
747
800
  }
748
- async function listBucketPaths(root) {
749
- 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) {
750
811
  if (error.code === "ENOENT") {
751
- return [];
812
+ return void 0;
752
813
  }
753
- throw error;
754
- });
755
- const nested = await Promise.all(entries.map(async (entry) => {
756
- const entryPath = join(root, entry.name);
757
- if (entry.isDirectory()) {
758
- 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;
759
853
  }
760
- return entry.name.endsWith(".json") ? [entryPath] : [];
761
- }));
762
- return nested.flat();
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;
878
+ }
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
+ }
763
887
  }
764
888
  function createSnapshot(key, bucket, maxAttempts) {
765
889
  const expiresAt = new Date(bucket.expiresAt.getTime());
@@ -776,16 +900,96 @@ function createFileRateLimitStore(root, options = {}) {
776
900
  const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
777
901
  const lockRetryDelayMs = options.lockRetryDelayMs ?? 5;
778
902
  const lockTimeoutMs = options.lockTimeoutMs ?? 2e3;
779
- 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 = {
780
984
  async hit(key, hitOptions) {
781
- const now = resolveNow();
782
- const path = getBucketPath(root, key);
783
- return await withBucketLock(path, {
784
- retryDelayMs: lockRetryDelayMs,
785
- timeoutMs: lockTimeoutMs
786
- }, 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 () => {
787
990
  const existing = await readBucket(path);
788
- if (existing && existing.keyHash !== createBucketHash(key)) {
991
+ const now = resolveNow();
992
+ if (existing && existing.keyHash !== keyHash) {
789
993
  throw new Error(`[@holo-js/security] File rate-limit bucket hash collision detected for stored bucket ${existing.keyHash}.`);
790
994
  }
791
995
  if (existing && isExpired(existing, now)) {
@@ -795,9 +999,8 @@ function createFileRateLimitStore(root, options = {}) {
795
999
  ...existing,
796
1000
  attempts: existing.attempts + 1
797
1001
  } : {
798
- namespaceHash: createBucketNamespaceHash(key),
799
- keyHash: createBucketHash(key),
800
- prefixHashes: Object.freeze(createBucketPrefixHashes(key)),
1002
+ ...hashBucketKey(key, keyHash),
1003
+ keyHash,
801
1004
  attempts: 1,
802
1005
  expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
803
1006
  };
@@ -810,13 +1013,11 @@ function createFileRateLimitStore(root, options = {}) {
810
1013
  });
811
1014
  },
812
1015
  async clear(key) {
813
- const path = getBucketPath(root, key);
814
- return await withBucketLock(path, {
815
- retryDelayMs: lockRetryDelayMs,
816
- timeoutMs: lockTimeoutMs
817
- }, async () => {
1016
+ const keyHash = createBucketHash(key);
1017
+ const path = getBucketPathForKeyHash(root, keyHash);
1018
+ return await withBucketLock(path, lockOptions, async () => {
818
1019
  const existing = await readBucket(path);
819
- if (!existing || existing.keyHash !== createBucketHash(key)) {
1020
+ if (!existing || existing.keyHash !== keyHash) {
820
1021
  return false;
821
1022
  }
822
1023
  await deleteBucket(path);
@@ -826,12 +1027,9 @@ function createFileRateLimitStore(root, options = {}) {
826
1027
  async clearByPrefix(prefix) {
827
1028
  let cleared = 0;
828
1029
  const now = resolveNow();
829
- const paths = await listBucketPaths(root);
830
- for (const path of paths) {
831
- const removed = await withBucketLock(path, {
832
- retryDelayMs: lockRetryDelayMs,
833
- timeoutMs: lockTimeoutMs
834
- }, async () => {
1030
+ const prefixHash = createBucketHash(prefix);
1031
+ for await (const path of bucketFilePaths(bucketsRoot)) {
1032
+ const removed = await withBucketLock(path, lockOptions, async () => {
835
1033
  const bucket = await readBucket(path);
836
1034
  if (!bucket) {
837
1035
  return false;
@@ -840,7 +1038,7 @@ function createFileRateLimitStore(root, options = {}) {
840
1038
  await deleteBucket(path);
841
1039
  return false;
842
1040
  }
843
- if (!bucket.prefixHashes.includes(createBucketHash(prefix))) {
1041
+ if (!bucket.prefixHashes.includes(prefixHash)) {
844
1042
  return false;
845
1043
  }
846
1044
  await deleteBucket(path);
@@ -853,13 +1051,9 @@ function createFileRateLimitStore(root, options = {}) {
853
1051
  return cleared;
854
1052
  },
855
1053
  async clearAll() {
856
- const paths = await listBucketPaths(root);
857
1054
  let cleared = 0;
858
- for (const path of paths) {
859
- const removed = await withBucketLock(path, {
860
- retryDelayMs: lockRetryDelayMs,
861
- timeoutMs: lockTimeoutMs
862
- }, async () => {
1055
+ for await (const path of bucketFilePaths(bucketsRoot)) {
1056
+ const removed = await withBucketLock(path, lockOptions, async () => {
863
1057
  const bucket = await readBucket(path);
864
1058
  if (!bucket) {
865
1059
  return false;
@@ -872,29 +1066,19 @@ function createFileRateLimitStore(root, options = {}) {
872
1066
  }
873
1067
  }
874
1068
  return cleared;
1069
+ },
1070
+ async close() {
1071
+ closed = true;
1072
+ await inFlight;
1073
+ await closeCursor();
875
1074
  }
876
1075
  };
1076
+ return store;
877
1077
  }
878
- var fileRateLimitDriverInternals = {
879
- createBucketHash,
880
- createSnapshot,
881
- deleteBucket,
882
- deserializeBucket,
883
- getBucketPath,
884
- isExpired,
885
- listBucketPaths,
886
- readBucket,
887
- serializeBucket,
888
- sleep,
889
- getBucketLockPath,
890
- acquireBucketLock,
891
- reclaimStaleBucketLock,
892
- removeOwnedBucketLock,
893
- withBucketLock,
894
- writeBucket
895
- };
896
1078
 
897
1079
  // src/drivers/memory.ts
1080
+ import { reportError as reportError2 } from "@holo-js/kernel/error-reporting";
1081
+ var DEFAULT_MEMORY_MAX_BUCKETS = 1e4;
898
1082
  function createSnapshot2(key, bucket, maxAttempts) {
899
1083
  const expiresAt = new Date(bucket.expiresAt.getTime());
900
1084
  return Object.freeze({
@@ -912,27 +1096,41 @@ function isExpired2(bucket, now) {
912
1096
  function createMemoryRateLimitStore(options = {}) {
913
1097
  const buckets = /* @__PURE__ */ new Map();
914
1098
  const resolveNow = options.now ?? (() => /* @__PURE__ */ new Date());
915
- const maxBuckets = options.maxBuckets;
916
- 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) {
917
1101
  throw new TypeError("[@holo-js/security] Memory rate-limit store maxBuckets must be an integer greater than or equal to 1.");
918
1102
  }
919
1103
  const pruneIntervalMs = typeof options.pruneIntervalMs === "number" ? options.pruneIntervalMs : 6e4;
920
1104
  if (!Number.isInteger(pruneIntervalMs) || pruneIntervalMs < 1) {
921
1105
  throw new TypeError("[@holo-js/security] Memory rate-limit store pruneIntervalMs must be an integer greater than or equal to 1.");
922
1106
  }
923
- const pruneExpiredBuckets = () => {
924
- 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) => {
925
1115
  for (const [key, bucket] of buckets) {
926
1116
  if (isExpired2(bucket, now)) {
927
1117
  buckets.delete(key);
928
1118
  }
929
1119
  }
1120
+ refreshNextExpiry();
930
1121
  };
931
- const pruneTimer = setInterval(pruneExpiredBuckets, pruneIntervalMs);
1122
+ const pruneTimer = setInterval(() => pruneExpiredBuckets(resolveNow()), pruneIntervalMs);
932
1123
  pruneTimer.unref?.();
933
- const evictOldestBucket = () => {
934
- const oldestKey = buckets.keys().next().value;
935
- 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
+ );
936
1134
  };
937
1135
  return {
938
1136
  async hit(key, hitOptions) {
@@ -950,15 +1148,29 @@ function createMemoryRateLimitStore(options = {}) {
950
1148
  attempts: 1,
951
1149
  expiresAt: new Date(now.getTime() + hitOptions.decaySeconds * 1e3)
952
1150
  };
953
- if (!active && typeof maxBuckets === "number") {
954
- while (buckets.size >= maxBuckets) {
955
- 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
+ });
956
1167
  }
957
1168
  }
958
1169
  if (active) {
959
1170
  buckets.delete(key);
960
1171
  }
961
1172
  buckets.set(key, bucket);
1173
+ nextExpiryAt = Math.min(nextExpiryAt, bucket.expiresAt.getTime());
962
1174
  return Object.freeze({
963
1175
  limited: bucket.attempts > hitOptions.maxAttempts,
964
1176
  snapshot: createSnapshot2(key, bucket, hitOptions.maxAttempts),
@@ -966,7 +1178,9 @@ function createMemoryRateLimitStore(options = {}) {
966
1178
  });
967
1179
  },
968
1180
  async clear(key) {
969
- return buckets.delete(key);
1181
+ const deleted = buckets.delete(key);
1182
+ refreshNextExpiry();
1183
+ return deleted;
970
1184
  },
971
1185
  async clearByPrefix(prefix) {
972
1186
  let cleared = 0;
@@ -977,11 +1191,13 @@ function createMemoryRateLimitStore(options = {}) {
977
1191
  buckets.delete(key);
978
1192
  cleared += 1;
979
1193
  }
1194
+ refreshNextExpiry();
980
1195
  return cleared;
981
1196
  },
982
1197
  async clearAll() {
983
1198
  const cleared = buckets.size;
984
1199
  buckets.clear();
1200
+ refreshNextExpiry();
985
1201
  return cleared;
986
1202
  },
987
1203
  async close() {
@@ -1087,7 +1303,7 @@ var securityStoreInternals = {
1087
1303
  };
1088
1304
 
1089
1305
  // src/signed-tokens.ts
1090
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1306
+ import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
1091
1307
  var MAX_SIGNED_TOKEN_DEPTH = 32;
1092
1308
  var MAX_SIGNED_TOKEN_BODY_BYTES = 64 * 1024;
1093
1309
  var MAX_SIGNED_TOKEN_CHARACTERS = Math.ceil(MAX_SIGNED_TOKEN_BODY_BYTES * 4 / 3) + 44;
@@ -1139,7 +1355,7 @@ function assertPayload(payload) {
1139
1355
  }
1140
1356
  }
1141
1357
  function signature(body, purpose) {
1142
- 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();
1143
1359
  }
1144
1360
  function decodeEnvelope(body) {
1145
1361
  try {
@@ -1215,6 +1431,7 @@ export {
1215
1431
  getSecurityRuntimeBindings,
1216
1432
  resetSecurityRuntime,
1217
1433
  securityRuntimeInternals,
1434
+ ip,
1218
1435
  defaultRateLimitKey,
1219
1436
  rateLimit,
1220
1437
  clearRateLimit,
@@ -1223,6 +1440,7 @@ export {
1223
1440
  token,
1224
1441
  field,
1225
1442
  input,
1443
+ encryptedToken,
1226
1444
  cookie,
1227
1445
  verify,
1228
1446
  protect,
@@ -1234,7 +1452,6 @@ export {
1234
1452
  cors,
1235
1453
  corsInternals,
1236
1454
  createFileRateLimitStore,
1237
- fileRateLimitDriverInternals,
1238
1455
  createMemoryRateLimitStore,
1239
1456
  memoryRateLimitDriverInternals,
1240
1457
  createRedisRateLimitStore,