@profullstack/throttle 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -110,7 +110,8 @@ selling the same minute twice.
110
110
  | `gateway` | — | an `@profullstack/x402-gateway`; makes over-limit a 402 |
111
111
  | `rules` | `[]` | per-path allowances; most specific match wins |
112
112
  | `credential` | `{ limit: 600, ceiling: 1200 }` | budget for callers presenting one |
113
- | `exempt` | — | never metered, e.g. a signed-in session |
113
+ | `exempt` | — | never metered, e.g. a health check |
114
+ | `credentialFrom` | auth headers | what counts as a credential; read the session cookie too |
114
115
  | `openPaths` | — | extra paths never metered |
115
116
  | `identify` | the caller's address | what to count against |
116
117
  | `store` | per-process | supply one to share a count across a fleet |
@@ -142,6 +143,18 @@ Any auth scheme counts, not just `Bearer` — matching Bearer alone once dropped
142
143
  a signed wallet's bulk payout into the anonymous bucket, which it exhausted in
143
144
  seconds.
144
145
 
146
+ A site whose customers arrive in a browser should count its session cookie as a
147
+ credential, so a signed-in merchant watching a dashboard is not metered as an
148
+ anonymous scraper:
149
+
150
+ ```js
151
+ credentialFrom: (request) =>
152
+ sessionIdFrom(request) ?? presentedCredential(request.headers),
153
+ ```
154
+
155
+ Prefer that to `exempt`. Exempting sessions outright hands an unmetered site to
156
+ anyone willing to sign up first.
157
+
145
158
  `credential: false` on a rule keeps it address-bucketed however the request is
146
159
  authenticated. **The sign-in routes need this**, or a brute-force attempt bolts
147
160
  an `Authorization` header onto every guess and buys itself the integration
package/index.d.ts CHANGED
@@ -48,8 +48,14 @@ export interface ThrottleOptions {
48
48
  store?: QuotaStore;
49
49
  /** What to count against. Default: the caller's address, as the edge reported it. */
50
50
  identify?: (request: Request) => string | null;
51
- /** Never metered, e.g. a request carrying a signed-in session cookie. */
51
+ /** Never metered, e.g. a health check. */
52
52
  exempt?: (request: Request) => boolean;
53
+ /**
54
+ * What counts as a credential for the larger budget. Default: an
55
+ * `Authorization` or `X-API-Key` header. Supply one that also reads a
56
+ * session cookie so signed-in customers are not metered as anonymous.
57
+ */
58
+ credentialFrom?: (request: Request) => string | null;
53
59
  /** Extra paths never metered. The gateway's sales page is added for you. */
54
60
  openPaths?: string[];
55
61
  /** Per-path allowances. Most specific match wins, whatever order they are in. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/throttle",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "An app-wide rate limit whose answer to going over is a price, not a 429. 100 requests a minute free, then x402 Payment Required with an offer, settled by CoinPay. Meters every route, so a scraper wearing a browser user agent is charged like any other crawler.",
6
6
  "keywords": [
package/src/throttle.js CHANGED
@@ -40,7 +40,8 @@ import { memoryStore } from './store.js';
40
40
  * @param {object} [options.gateway] a @profullstack/x402-gateway; over-limit is answered 402 with its offer
41
41
  * @param {object} [options.store] shared counter; default is per-process
42
42
  * @param {(request: Request) => string|null} [options.identify] what to count against; default the caller's address
43
- * @param {(request: Request) => boolean} [options.exempt] never metered, e.g. a signed-in session
43
+ * @param {(request: Request) => boolean} [options.exempt] never metered, e.g. a health check
44
+ * @param {(request: Request) => string|null} [options.credentialFrom] what counts as a credential; default an Authorization or X-API-Key header
44
45
  * @param {string[]} [options.openPaths] extra paths never metered
45
46
  * @param {Array} [options.rules] per-path allowances, most specific wins
46
47
  * @param {{limit?: number, ceiling?: number}} [options.credential] budget for callers presenting a credential
@@ -59,6 +60,14 @@ export function createThrottle(options = {}) {
59
60
  const rules = compileRules(options.rules, defaults);
60
61
  const gateway = options.gateway ?? null;
61
62
  const identify = options.identify ?? addressOf;
63
+ /*
64
+ * What counts as a credential. The default reads the request's own auth
65
+ * headers, but a site whose customers arrive in a browser needs its session
66
+ * cookie to count too: a signed-in merchant watching a dashboard is not a
67
+ * scraper, and the alternative -- exempting sessions outright -- hands an
68
+ * unmetered site to anyone willing to sign up first.
69
+ */
70
+ const credentialFrom = options.credentialFrom ?? ((request) => presentedCredential(request.headers));
62
71
  const exempt = options.exempt ?? null;
63
72
  const onThrottle = options.onThrottle ?? null;
64
73
 
@@ -179,7 +188,7 @@ export function createThrottle(options = {}) {
179
188
  * budget.
180
189
  */
181
190
  const honoursCredential = Boolean(credential) && rule.credential !== false;
182
- const presented = honoursCredential ? presentedCredential(request.headers) : null;
191
+ const presented = honoursCredential ? credentialFrom(request) : null;
183
192
  if (presented) {
184
193
  const ceilingRule = { ...rule, limit: rule.credential?.ceiling ?? credential.ceiling };
185
194
  const perKeyRule = { ...rule, limit: rule.credential?.limit ?? credential.limit };