@majikah/majik-api 0.1.1 → 0.1.2

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,4 +1,4 @@
1
- import type { DomainWhitelist, IPWhitelist, MajikAPICreateOptions, MajikAPIJSON, MajikAPISettings, RateLimit, RateLimitFrequency } from "./types";
1
+ import type { DomainWhitelist, IPWhitelist, MajikAPICreateOptions, MajikAPIJSON, MajikAPISettings, Quota, QuotaFrequency, RateLimit, RateLimitFrequency } from "./types";
2
2
  export declare class MajikAPI {
3
3
  private readonly _id;
4
4
  private readonly _owner_id;
@@ -26,12 +26,14 @@ export declare class MajikAPI {
26
26
  * Accepts the raw output of `toJSON()`, a Supabase row, or a Redis cache hit.
27
27
  *
28
28
  * `raw_api_key` is intentionally NOT restored — it is never in the JSON.
29
+ * `is_valid` is intentionally NOT restored — it is a computed getter.
29
30
  */
30
31
  static fromJSON(data: MajikAPIJSON): MajikAPI;
31
32
  /**
32
33
  * Serialise to a plain JSON-safe object matching MajikAPIJSON.
33
34
  * Safe to store in Supabase or cache in Redis.
34
35
  * raw_api_key is NEVER included.
36
+ * is_valid is computed at serialisation time from isActive().
35
37
  */
36
38
  toJSON(): MajikAPIJSON;
37
39
  /**
@@ -58,14 +60,61 @@ export declare class MajikAPI {
58
60
  * (500 req/min). Attempting to set a higher rate will throw unless
59
61
  * bypassSafeLimit is explicitly passed as true.
60
62
  *
61
- * @param amount - Number of allowed requests per frequency window.
62
- * @param frequency - The time window unit.
63
+ * @param amount - Number of allowed requests per frequency window.
64
+ * @param frequency - The time window unit.
63
65
  * @param bypassSafeLimit - When true, skips the MAX_RATE_LIMIT ceiling check.
64
- * Defaults to false. Use with caution.
66
+ * Defaults to false. Use with caution.
65
67
  */
66
68
  setRateLimit(amount: number, frequency: RateLimitFrequency, bypassSafeLimit?: boolean): void;
67
69
  /** Reset the rate limit back to DEFAULT_RATE_LIMIT. */
68
70
  resetRateLimit(): void;
71
+ /**
72
+ * Set a fixed lifetime quota for this key.
73
+ * Once total usage reaches `limit`, isQuotaExceeded() returns true.
74
+ *
75
+ * @param limit - Maximum total number of requests allowed. Must be a
76
+ * positive integer.
77
+ *
78
+ * @example
79
+ * key.setFixedQuota(10_000); // 10 000 requests total, ever
80
+ */
81
+ setFixedQuota(limit: number): void;
82
+ /**
83
+ * Set a periodic rolling quota for this key.
84
+ * Usage is expected to be tracked externally (e.g. in Redis or Supabase)
85
+ * and passed into isQuotaExceeded() for comparison.
86
+ *
87
+ * @param limit - Maximum number of requests allowed per `frequency` window.
88
+ * @param frequency - The time window unit.
89
+ *
90
+ * @example
91
+ * key.setPeriodicQuota(50_000, "months"); // 50k requests per month
92
+ * key.setPeriodicQuota(1_000, "days"); // 1k requests per day
93
+ */
94
+ setPeriodicQuota(limit: number, frequency: QuotaFrequency): void;
95
+ /**
96
+ * Remove any quota restriction from this key.
97
+ * After calling this, isQuotaExceeded() will always return false.
98
+ */
99
+ clearQuota(): void;
100
+ /**
101
+ * Check whether the given usage count has met or exceeded this key's quota.
102
+ *
103
+ * This method does NOT track usage itself — `currentUsage` must be supplied
104
+ * by the caller from whatever store you use (Redis counter, Supabase
105
+ * aggregate, etc.).
106
+ *
107
+ * For a `fixed` quota, pass the key's all-time request count.
108
+ * For a `periodic` quota, pass the request count for the current window.
109
+ *
110
+ * Returns:
111
+ * - `false` if quota is null (unlimited).
112
+ * - `true` if currentUsage >= the configured limit.
113
+ * - `false` if currentUsage < the configured limit.
114
+ *
115
+ * @param currentUsage - The usage count to check against the quota.
116
+ */
117
+ isQuotaExceeded(currentUsage: number): boolean;
69
118
  /**
70
119
  * Rotate the API key. Generates a new raw key (or accepts a provided one),
71
120
  * hashes it, and replaces _api_key. The stable _id and _owner_id are
@@ -137,9 +186,33 @@ export declare class MajikAPI {
137
186
  get timestamp(): string;
138
187
  get restricted(): boolean;
139
188
  get validUntil(): Date | null;
189
+ /**
190
+ * Whether this key is valid for use right now.
191
+ * True when the key is active (not expired, not restricted).
192
+ *
193
+ * Note: this does NOT factor in quota — quota is a runtime check that
194
+ * requires external usage data. Use isQuotaExceeded(currentUsage) for that.
195
+ */
196
+ get is_valid(): boolean;
140
197
  /** Returns a deep clone — mutations to the returned object have no effect. */
141
198
  get settings(): Readonly<MajikAPISettings>;
142
199
  get rateLimit(): Readonly<RateLimit>;
200
+ /**
201
+ * The current quota configuration.
202
+ * null means unlimited.
203
+ * Use isQuotaExceeded(currentUsage) to compare against live usage.
204
+ */
205
+ get quota(): Readonly<Quota>;
206
+ /**
207
+ * The configured quota limit, or null if no quota is set.
208
+ * Convenience shorthand for `key.quota?.limit ?? null`.
209
+ */
210
+ get quotaLimit(): number | null;
211
+ /**
212
+ * The quota frequency if this is a periodic quota, otherwise null.
213
+ * Useful for determining the reset window without inspecting the full quota object.
214
+ */
215
+ get quotaFrequency(): QuotaFrequency | null;
143
216
  get ipWhitelist(): Readonly<IPWhitelist>;
144
217
  get domainWhitelist(): Readonly<DomainWhitelist>;
145
218
  get allowedMethods(): string[];
@@ -159,6 +232,7 @@ export declare class MajikAPI {
159
232
  */
160
233
  get status(): "active" | "restricted" | "expired" | "revoked";
161
234
  private static parseDate;
235
+ private static assertQuotaFrequency;
162
236
  private static validateSettings;
163
237
  toString(): string;
164
238
  }
package/dist/majik-api.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { DEFAULT_RATE_LIMIT, MAX_RATE_LIMIT, TO_MINUTES } from "./constants";
2
2
  import { assertBoolean, assertPositiveInteger, assertRateLimitFrequency, assertString, assertStringArray, buildDefaultSettings, generateID, isValidISODate, sha256, validateDomain, validateIP, } from "./utils";
3
3
  // ─────────────────────────────────────────────
4
+ // Constants
5
+ // ─────────────────────────────────────────────
6
+ const VALID_QUOTA_FREQUENCIES = [
7
+ "hours",
8
+ "days",
9
+ "weeks",
10
+ "months",
11
+ "quarters",
12
+ "years",
13
+ ];
14
+ // ─────────────────────────────────────────────
4
15
  // MajikAPI Class
5
16
  // ─────────────────────────────────────────────
6
17
  export class MajikAPI {
@@ -98,6 +109,7 @@ export class MajikAPI {
98
109
  * Accepts the raw output of `toJSON()`, a Supabase row, or a Redis cache hit.
99
110
  *
100
111
  * `raw_api_key` is intentionally NOT restored — it is never in the JSON.
112
+ * `is_valid` is intentionally NOT restored — it is a computed getter.
101
113
  */
102
114
  static fromJSON(data) {
103
115
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -134,6 +146,7 @@ export class MajikAPI {
134
146
  * Serialise to a plain JSON-safe object matching MajikAPIJSON.
135
147
  * Safe to store in Supabase or cache in Redis.
136
148
  * raw_api_key is NEVER included.
149
+ * is_valid is computed at serialisation time from isActive().
137
150
  */
138
151
  toJSON() {
139
152
  return {
@@ -144,6 +157,7 @@ export class MajikAPI {
144
157
  timestamp: this._timestamp.toISOString(),
145
158
  restricted: this._restricted,
146
159
  valid_until: this._valid_until ? this._valid_until.toISOString() : null,
160
+ is_valid: this.is_valid,
147
161
  settings: structuredClone(this._settings),
148
162
  };
149
163
  }
@@ -213,10 +227,10 @@ export class MajikAPI {
213
227
  * (500 req/min). Attempting to set a higher rate will throw unless
214
228
  * bypassSafeLimit is explicitly passed as true.
215
229
  *
216
- * @param amount - Number of allowed requests per frequency window.
217
- * @param frequency - The time window unit.
230
+ * @param amount - Number of allowed requests per frequency window.
231
+ * @param frequency - The time window unit.
218
232
  * @param bypassSafeLimit - When true, skips the MAX_RATE_LIMIT ceiling check.
219
- * Defaults to false. Use with caution.
233
+ * Defaults to false. Use with caution.
220
234
  */
221
235
  setRateLimit(amount, frequency, bypassSafeLimit = false) {
222
236
  assertPositiveInteger(amount, "amount");
@@ -240,6 +254,70 @@ export class MajikAPI {
240
254
  this._settings.rateLimit = { ...DEFAULT_RATE_LIMIT };
241
255
  }
242
256
  // ─────────────────────────────────────────────
257
+ // Quota Management
258
+ // ─────────────────────────────────────────────
259
+ /**
260
+ * Set a fixed lifetime quota for this key.
261
+ * Once total usage reaches `limit`, isQuotaExceeded() returns true.
262
+ *
263
+ * @param limit - Maximum total number of requests allowed. Must be a
264
+ * positive integer.
265
+ *
266
+ * @example
267
+ * key.setFixedQuota(10_000); // 10 000 requests total, ever
268
+ */
269
+ setFixedQuota(limit) {
270
+ assertPositiveInteger(limit, "limit");
271
+ this._settings.quota = { type: "fixed", limit };
272
+ }
273
+ /**
274
+ * Set a periodic rolling quota for this key.
275
+ * Usage is expected to be tracked externally (e.g. in Redis or Supabase)
276
+ * and passed into isQuotaExceeded() for comparison.
277
+ *
278
+ * @param limit - Maximum number of requests allowed per `frequency` window.
279
+ * @param frequency - The time window unit.
280
+ *
281
+ * @example
282
+ * key.setPeriodicQuota(50_000, "months"); // 50k requests per month
283
+ * key.setPeriodicQuota(1_000, "days"); // 1k requests per day
284
+ */
285
+ setPeriodicQuota(limit, frequency) {
286
+ assertPositiveInteger(limit, "limit");
287
+ MajikAPI.assertQuotaFrequency(frequency, "frequency");
288
+ this._settings.quota = { type: "periodic", limit, frequency };
289
+ }
290
+ /**
291
+ * Remove any quota restriction from this key.
292
+ * After calling this, isQuotaExceeded() will always return false.
293
+ */
294
+ clearQuota() {
295
+ this._settings.quota = null;
296
+ }
297
+ /**
298
+ * Check whether the given usage count has met or exceeded this key's quota.
299
+ *
300
+ * This method does NOT track usage itself — `currentUsage` must be supplied
301
+ * by the caller from whatever store you use (Redis counter, Supabase
302
+ * aggregate, etc.).
303
+ *
304
+ * For a `fixed` quota, pass the key's all-time request count.
305
+ * For a `periodic` quota, pass the request count for the current window.
306
+ *
307
+ * Returns:
308
+ * - `false` if quota is null (unlimited).
309
+ * - `true` if currentUsage >= the configured limit.
310
+ * - `false` if currentUsage < the configured limit.
311
+ *
312
+ * @param currentUsage - The usage count to check against the quota.
313
+ */
314
+ isQuotaExceeded(currentUsage) {
315
+ if (this._settings.quota === null)
316
+ return false;
317
+ assertPositiveInteger(currentUsage, "currentUsage");
318
+ return currentUsage >= this._settings.quota.limit;
319
+ }
320
+ // ─────────────────────────────────────────────
243
321
  // Key Rotation
244
322
  // ─────────────────────────────────────────────
245
323
  /**
@@ -449,6 +527,16 @@ export class MajikAPI {
449
527
  get validUntil() {
450
528
  return this._valid_until ? new Date(this._valid_until) : null;
451
529
  }
530
+ /**
531
+ * Whether this key is valid for use right now.
532
+ * True when the key is active (not expired, not restricted).
533
+ *
534
+ * Note: this does NOT factor in quota — quota is a runtime check that
535
+ * requires external usage data. Use isQuotaExceeded(currentUsage) for that.
536
+ */
537
+ get is_valid() {
538
+ return this.isActive();
539
+ }
452
540
  /** Returns a deep clone — mutations to the returned object have no effect. */
453
541
  get settings() {
454
542
  return structuredClone(this._settings);
@@ -456,6 +544,31 @@ export class MajikAPI {
456
544
  get rateLimit() {
457
545
  return { ...this._settings.rateLimit };
458
546
  }
547
+ /**
548
+ * The current quota configuration.
549
+ * null means unlimited.
550
+ * Use isQuotaExceeded(currentUsage) to compare against live usage.
551
+ */
552
+ get quota() {
553
+ return this._settings.quota ? structuredClone(this._settings.quota) : null;
554
+ }
555
+ /**
556
+ * The configured quota limit, or null if no quota is set.
557
+ * Convenience shorthand for `key.quota?.limit ?? null`.
558
+ */
559
+ get quotaLimit() {
560
+ return this._settings.quota?.limit ?? null;
561
+ }
562
+ /**
563
+ * The quota frequency if this is a periodic quota, otherwise null.
564
+ * Useful for determining the reset window without inspecting the full quota object.
565
+ */
566
+ get quotaFrequency() {
567
+ if (this._settings.quota?.type === "periodic") {
568
+ return this._settings.quota.frequency;
569
+ }
570
+ return null;
571
+ }
459
572
  get ipWhitelist() {
460
573
  return structuredClone(this._settings.ipWhitelist);
461
574
  }
@@ -510,12 +623,33 @@ export class MajikAPI {
510
623
  }
511
624
  throw new TypeError(`[MajikAPI] "${label}" must be a Date instance or an ISO date string.`);
512
625
  }
626
+ static assertQuotaFrequency(value, label) {
627
+ if (!VALID_QUOTA_FREQUENCIES.includes(value)) {
628
+ throw new TypeError(`[MajikAPI] "${label}" must be one of: ${VALID_QUOTA_FREQUENCIES.join(", ")}. Got: "${value}"`);
629
+ }
630
+ }
513
631
  static validateSettings(settings) {
514
632
  if (typeof settings !== "object" || settings === null) {
515
633
  throw new TypeError("[MajikAPI] 'settings' must be an object.");
516
634
  }
517
635
  assertPositiveInteger(settings.rateLimit?.amount, "settings.rateLimit.amount");
518
636
  assertRateLimitFrequency(settings.rateLimit?.frequency, "settings.rateLimit.frequency");
637
+ // Validate quota if present
638
+ if (settings.quota !== null && settings.quota !== undefined) {
639
+ if (typeof settings.quota !== "object") {
640
+ throw new TypeError("[MajikAPI] 'settings.quota' must be an object or null.");
641
+ }
642
+ assertPositiveInteger(settings.quota.limit, "settings.quota.limit");
643
+ if (settings.quota.type === "fixed") {
644
+ // no extra fields to validate
645
+ }
646
+ else if (settings.quota.type === "periodic") {
647
+ MajikAPI.assertQuotaFrequency(settings.quota.frequency, "settings.quota.frequency");
648
+ }
649
+ else {
650
+ throw new TypeError(`[MajikAPI] 'settings.quota.type' must be "fixed" or "periodic". Got: "${settings.quota.type}"`);
651
+ }
652
+ }
519
653
  assertBoolean(settings.ipWhitelist?.enabled, "settings.ipWhitelist.enabled");
520
654
  assertStringArray(settings.ipWhitelist?.addresses, "settings.ipWhitelist.addresses");
521
655
  settings.ipWhitelist.addresses.forEach((ip) => validateIP(ip));
@@ -527,7 +661,7 @@ export class MajikAPI {
527
661
  // Debug
528
662
  // ─────────────────────────────────────────────
529
663
  toString() {
530
- return `[MajikAPI id="${this._id}" owner="${this._owner_id}" name="${this._name}" status="${this.status}"]`;
664
+ return `[MajikAPI id="${this._id}" owner="${this._owner_id}" name="${this._name}" status="${this.status}" is_valid=${this.is_valid}]`;
531
665
  }
532
666
  [Symbol.for("nodejs.util.inspect.custom")]() {
533
667
  return this.toString();
package/dist/types.d.ts CHANGED
@@ -1,8 +1,29 @@
1
1
  export type RateLimitFrequency = "seconds" | "minutes" | "hours";
2
+ export type QuotaFrequency = "hours" | "days" | "weeks" | "months" | "quarters" | "years";
2
3
  export interface RateLimit {
3
4
  amount: number;
4
5
  frequency: RateLimitFrequency;
5
6
  }
7
+ /**
8
+ * Quota controls how many total requests (or requests within a rolling window)
9
+ * are permitted for this key.
10
+ *
11
+ * - `fixed` — A lifetime cap. Once `limit` total requests have been made,
12
+ * the key is considered at quota. No time window applies.
13
+ *
14
+ * - `periodic` — A rolling/periodic cap. Resets every `frequency` window
15
+ * (e.g. 1 000 requests per day, 50 000 per month).
16
+ *
17
+ * - `null` — No quota. Unlimited usage (subject only to rate limiting).
18
+ */
19
+ export type Quota = {
20
+ type: "fixed";
21
+ limit: number;
22
+ } | {
23
+ type: "periodic";
24
+ limit: number;
25
+ frequency: QuotaFrequency;
26
+ } | null;
6
27
  export interface IPWhitelist {
7
28
  enabled: boolean;
8
29
  addresses: string[];
@@ -13,6 +34,7 @@ export interface DomainWhitelist {
13
34
  }
14
35
  export interface MajikAPISettings {
15
36
  rateLimit: RateLimit;
37
+ quota: Quota;
16
38
  ipWhitelist: IPWhitelist;
17
39
  domainWhitelist: DomainWhitelist;
18
40
  allowedMethods?: string[];
@@ -27,6 +49,9 @@ export interface MajikAPISettings {
27
49
  * api_key — SHA-256 hash of the raw plaintext key. Has a UNIQUE INDEX in
28
50
  * Postgres (not the PK). Used as the Redis cache key prefix.
29
51
  * The raw key is never stored anywhere.
52
+ * is_valid — Computed convenience flag. True when the key is active (not
53
+ * expired, not restricted). Does NOT account for quota — use
54
+ * isQuotaExceeded() for runtime quota checks.
30
55
  */
31
56
  export interface MajikAPIJSON {
32
57
  id: string;
@@ -36,6 +61,7 @@ export interface MajikAPIJSON {
36
61
  timestamp: string;
37
62
  restricted: boolean;
38
63
  valid_until: string | null;
64
+ is_valid: boolean;
39
65
  settings: MajikAPISettings;
40
66
  }
41
67
  export interface MajikAPICreateOptions {
package/dist/utils.js CHANGED
@@ -105,5 +105,6 @@ export function buildDefaultSettings(overrides) {
105
105
  },
106
106
  allowedMethods: overrides?.allowedMethods ?? [],
107
107
  metadata: overrides?.metadata ?? {},
108
+ quota: overrides?.quota ?? null,
108
109
  };
109
110
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@majikah/majik-api",
3
3
  "type": "module",
4
4
  "description": "A high-security API key management library for TypeScript. Handles generation, SHA-256 hashing, and lifecycle management including rate limiting, IP/domain whitelisting, and secure key rotation.",
5
- "version": "0.1.1",
5
+ "version": "0.1.2",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",