// src/cross-namespace-budget.ts
var DEFAULT_CROSS_NAMESPACE_BUDGET = Object.freeze({
  enabled: false,
  windowMs: 6e4,
  softLimit: 10,
  hardLimit: 30
});
function effectiveConfig(raw) {
  const base = { ...DEFAULT_CROSS_NAMESPACE_BUDGET };
  if (!raw) return base;
  const out = { ...base };
  if (typeof raw.enabled === "boolean") out.enabled = raw.enabled;
  if (typeof raw.windowMs === "number" && Number.isFinite(raw.windowMs) && raw.windowMs > 0) {
    out.windowMs = raw.windowMs;
  }
  if (typeof raw.softLimit === "number" && Number.isFinite(raw.softLimit) && raw.softLimit >= 0) {
    out.softLimit = Math.floor(raw.softLimit);
  }
  if (typeof raw.hardLimit === "number" && Number.isFinite(raw.hardLimit) && raw.hardLimit >= 1) {
    const floored = Math.floor(raw.hardLimit);
    if (floored >= 1) out.hardLimit = floored;
  }
  if (out.softLimit > out.hardLimit) {
    out.softLimit = out.hardLimit;
  }
  return out;
}
var CrossNamespaceBudget = class {
  config;
  buckets = /* @__PURE__ */ new Map();
  constructor(config) {
    this.config = effectiveConfig(config);
  }
  /** Exposed for tests / audit surfaces. Never mutate the returned value. */
  getConfig() {
    return this.config;
  }
  /**
   * Check whether `principal` is allowed to issue another cross-namespace
   * read. Call site is expected to compare `principalNamespace` against
   * `queryNamespace` and only pass through reads where they differ — the
   * limiter treats every call as a cross-namespace event.
   *
   * @param principal Stable identifier for the calling principal (token
   *   subject, session principal, etc.). Must be non-empty.
   * @param now Epoch-ms clock read. Defaults to `Date.now()`; tests pass a
   *   fixed value to step time deterministically.
   */
  record(principal, now = Date.now()) {
    const { enabled, windowMs, softLimit, hardLimit } = this.config;
    const limit = { softLimit, hardLimit, windowMs };
    if (!enabled) {
      return {
        allowed: true,
        reason: "allowed-no-limit",
        count: 0,
        limit
      };
    }
    if (typeof principal !== "string" || principal.length === 0) {
      principal = "__anonymous__";
    }
    const bucket = this.buckets.get(principal) ?? { timestamps: [] };
    const cutoff = now - windowMs;
    while (bucket.timestamps.length > 0 && bucket.timestamps[0] < cutoff) {
      bucket.timestamps.shift();
    }
    bucket.timestamps.push(now);
    this.buckets.set(principal, bucket);
    const count = bucket.timestamps.length;
    if (count > hardLimit) {
      bucket.timestamps.pop();
      if (bucket.timestamps.length === 0) {
        this.buckets.delete(principal);
      }
      return {
        allowed: false,
        reason: "deny-over-hard",
        count: bucket.timestamps.length,
        limit
      };
    }
    if (count > softLimit) {
      return {
        allowed: true,
        reason: "warn-over-soft",
        count,
        limit
      };
    }
    return {
      allowed: true,
      reason: "allowed-under-soft",
      count,
      limit
    };
  }
  /**
   * Read-only peek at whether a call would be allowed, WITHOUT recording a
   * timestamp. Useful when the caller must inspect multiple namespaces before
   * deciding to record a single event. The returned `count` reflects the
   * current window state at call time.
   */
  peek(args) {
    const pn = args.principalNamespace;
    const qn = args.queryNamespace;
    const bothPresent = typeof pn === "string" && pn.length > 0 && typeof qn === "string" && qn.length > 0;
    if (bothPresent && pn === qn) {
      return {
        allowed: true,
        reason: "allowed-same-namespace",
        count: 0,
        limit: {
          softLimit: this.config.softLimit,
          hardLimit: this.config.hardLimit,
          windowMs: this.config.windowMs
        }
      };
    }
    const { enabled, windowMs, softLimit, hardLimit } = this.config;
    const limit = { softLimit, hardLimit, windowMs };
    if (!enabled) {
      return { allowed: true, reason: "allowed-no-limit", count: 0, limit };
    }
    let principal = args.principal;
    if (typeof principal !== "string" || principal.length === 0) {
      principal = "__anonymous__";
    }
    const now = args.now ?? Date.now();
    const bucket = this.buckets.get(principal) ?? { timestamps: [] };
    const cutoff = now - windowMs;
    let liveCount = 0;
    for (const ts of bucket.timestamps) {
      if (ts >= cutoff) liveCount++;
    }
    const projected = liveCount + 1;
    if (projected > hardLimit) {
      return { allowed: false, reason: "deny-over-hard", count: liveCount, limit };
    }
    if (projected > softLimit) {
      return { allowed: true, reason: "warn-over-soft", count: projected, limit };
    }
    return { allowed: true, reason: "allowed-under-soft", count: projected, limit };
  }
  /**
   * Convenience guard that also skips the limiter when `principalNamespace`
   * equals `queryNamespace` (same-namespace is never cross-namespace).
   * Returns an `allowed-same-namespace` decision in that case.
   */
  check(args) {
    const pn = args.principalNamespace;
    const qn = args.queryNamespace;
    const bothPresent = typeof pn === "string" && pn.length > 0 && typeof qn === "string" && qn.length > 0;
    if (bothPresent && pn === qn) {
      return {
        allowed: true,
        reason: "allowed-same-namespace",
        count: 0,
        limit: {
          softLimit: this.config.softLimit,
          hardLimit: this.config.hardLimit,
          windowMs: this.config.windowMs
        }
      };
    }
    return this.record(args.principal, args.now ?? Date.now());
  }
  /**
   * Clear all state. Intended for tests and for the orchestrator's
   * lifecycle `before_reset` hook.
   */
  reset() {
    this.buckets.clear();
  }
  /**
   * Evict buckets whose entire timestamp list has slid out of the
   * active window by `now`. Intended to be called periodically by a
   * long-lived host process (e.g. from a maintenance cron) that sees
   * many transient principals. Safe to call at any time; returns the
   * number of buckets evicted.
   */
  gc(now = Date.now()) {
    const cutoff = now - this.config.windowMs;
    let evicted = 0;
    for (const [principal, bucket] of this.buckets.entries()) {
      while (bucket.timestamps.length > 0 && bucket.timestamps[0] < cutoff) {
        bucket.timestamps.shift();
      }
      if (bucket.timestamps.length === 0) {
        this.buckets.delete(principal);
        evicted++;
      }
    }
    return evicted;
  }
  /** For tests: current number of live buckets. */
  bucketCount() {
    return this.buckets.size;
  }
};

export {
  DEFAULT_CROSS_NAMESPACE_BUDGET,
  CrossNamespaceBudget
};
//# sourceMappingURL=chunk-GDFS42HT.js.map