@majikah/majik-api 0.1.0 → 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.
@@ -0,0 +1,11 @@
1
+ import { RateLimit, RateLimitFrequency } from "./types";
2
+ export declare const DEFAULT_RATE_LIMIT: RateLimit;
3
+ /**
4
+ * Hard ceiling for any rate limit set on a MajikAPI key.
5
+ * No key — regardless of trust level — may exceed this without bypassSafeLimit.
6
+ * Expressed in req/min for normalisation purposes; stored as a RateLimit for
7
+ * consistency with the rest of the API.
8
+ */
9
+ export declare const MAX_RATE_LIMIT: RateLimit;
10
+ /** Multipliers to convert each frequency unit into requests-per-minute. */
11
+ export declare const TO_MINUTES: Record<RateLimitFrequency, number>;
@@ -0,0 +1,23 @@
1
+ // ─────────────────────────────────────────────
2
+ // Constants
3
+ // ─────────────────────────────────────────────
4
+ export const DEFAULT_RATE_LIMIT = {
5
+ amount: 100,
6
+ frequency: "minutes",
7
+ };
8
+ /**
9
+ * Hard ceiling for any rate limit set on a MajikAPI key.
10
+ * No key — regardless of trust level — may exceed this without bypassSafeLimit.
11
+ * Expressed in req/min for normalisation purposes; stored as a RateLimit for
12
+ * consistency with the rest of the API.
13
+ */
14
+ export const MAX_RATE_LIMIT = {
15
+ amount: 500,
16
+ frequency: "minutes",
17
+ };
18
+ /** Multipliers to convert each frequency unit into requests-per-minute. */
19
+ export const TO_MINUTES = {
20
+ seconds: 1 / 60,
21
+ minutes: 1,
22
+ hours: 60,
23
+ };
@@ -1,12 +1,4 @@
1
- import type { DomainWhitelist, IPWhitelist, MajikAPICreateOptions, MajikAPIJSON, MajikAPISettings, RateLimit, RateLimitFrequency } from "./types";
2
- export declare const DEFAULT_RATE_LIMIT: RateLimit;
3
- /**
4
- * Hard ceiling for any rate limit set on a MajikAPI key.
5
- * No key — regardless of trust level — may exceed this without bypassSafeLimit.
6
- * Expressed in req/min for normalisation purposes; stored as a RateLimit for
7
- * consistency with the rest of the API.
8
- */
9
- export declare const MAX_RATE_LIMIT: RateLimit;
1
+ import type { DomainWhitelist, IPWhitelist, MajikAPICreateOptions, MajikAPIJSON, MajikAPISettings, Quota, QuotaFrequency, RateLimit, RateLimitFrequency } from "./types";
10
2
  export declare class MajikAPI {
11
3
  private readonly _id;
12
4
  private readonly _owner_id;
@@ -34,12 +26,14 @@ export declare class MajikAPI {
34
26
  * Accepts the raw output of `toJSON()`, a Supabase row, or a Redis cache hit.
35
27
  *
36
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.
37
30
  */
38
31
  static fromJSON(data: MajikAPIJSON): MajikAPI;
39
32
  /**
40
33
  * Serialise to a plain JSON-safe object matching MajikAPIJSON.
41
34
  * Safe to store in Supabase or cache in Redis.
42
35
  * raw_api_key is NEVER included.
36
+ * is_valid is computed at serialisation time from isActive().
43
37
  */
44
38
  toJSON(): MajikAPIJSON;
45
39
  /**
@@ -66,14 +60,61 @@ export declare class MajikAPI {
66
60
  * (500 req/min). Attempting to set a higher rate will throw unless
67
61
  * bypassSafeLimit is explicitly passed as true.
68
62
  *
69
- * @param amount - Number of allowed requests per frequency window.
70
- * @param frequency - The time window unit.
63
+ * @param amount - Number of allowed requests per frequency window.
64
+ * @param frequency - The time window unit.
71
65
  * @param bypassSafeLimit - When true, skips the MAX_RATE_LIMIT ceiling check.
72
- * Defaults to false. Use with caution.
66
+ * Defaults to false. Use with caution.
73
67
  */
74
68
  setRateLimit(amount: number, frequency: RateLimitFrequency, bypassSafeLimit?: boolean): void;
75
69
  /** Reset the rate limit back to DEFAULT_RATE_LIMIT. */
76
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;
77
118
  /**
78
119
  * Rotate the API key. Generates a new raw key (or accepts a provided one),
79
120
  * hashes it, and replaces _api_key. The stable _id and _owner_id are
@@ -145,9 +186,33 @@ export declare class MajikAPI {
145
186
  get timestamp(): string;
146
187
  get restricted(): boolean;
147
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;
148
197
  /** Returns a deep clone — mutations to the returned object have no effect. */
149
198
  get settings(): Readonly<MajikAPISettings>;
150
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;
151
216
  get ipWhitelist(): Readonly<IPWhitelist>;
152
217
  get domainWhitelist(): Readonly<DomainWhitelist>;
153
218
  get allowedMethods(): string[];
@@ -167,6 +232,7 @@ export declare class MajikAPI {
167
232
  */
168
233
  get status(): "active" | "restricted" | "expired" | "revoked";
169
234
  private static parseDate;
235
+ private static assertQuotaFrequency;
170
236
  private static validateSettings;
171
237
  toString(): string;
172
238
  }
package/dist/majik-api.js CHANGED
@@ -1,108 +1,16 @@
1
- import { generateID, sha256 } from "./utils";
1
+ import { DEFAULT_RATE_LIMIT, MAX_RATE_LIMIT, TO_MINUTES } from "./constants";
2
+ import { assertBoolean, assertPositiveInteger, assertRateLimitFrequency, assertString, assertStringArray, buildDefaultSettings, generateID, isValidISODate, sha256, validateDomain, validateIP, } from "./utils";
2
3
  // ─────────────────────────────────────────────
3
4
  // Constants
4
5
  // ─────────────────────────────────────────────
5
- export const DEFAULT_RATE_LIMIT = {
6
- amount: 100,
7
- frequency: "minutes",
8
- };
9
- /**
10
- * Hard ceiling for any rate limit set on a MajikAPI key.
11
- * No key — regardless of trust level — may exceed this without bypassSafeLimit.
12
- * Expressed in req/min for normalisation purposes; stored as a RateLimit for
13
- * consistency with the rest of the API.
14
- */
15
- export const MAX_RATE_LIMIT = {
16
- amount: 500,
17
- frequency: "minutes",
18
- };
19
- /** Multipliers to convert each frequency unit into requests-per-minute. */
20
- const TO_MINUTES = {
21
- seconds: 1 / 60,
22
- minutes: 1,
23
- hours: 60,
24
- };
25
- // ─────────────────────────────────────────────
26
- // Validation Helpers
27
- // ─────────────────────────────────────────────
28
- function assertString(value, label) {
29
- if (typeof value !== "string" || value.trim() === "") {
30
- throw new TypeError(`[MajikAPI] "${label}" must be a non-empty string. Received: ${JSON.stringify(value)}`);
31
- }
32
- }
33
- function assertPositiveInteger(value, label) {
34
- if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
35
- throw new RangeError(`[MajikAPI] "${label}" must be a positive integer. Received: ${JSON.stringify(value)}`);
36
- }
37
- }
38
- function assertRateLimitFrequency(value, label) {
39
- const valid = ["seconds", "minutes", "hours"];
40
- if (!valid.includes(value)) {
41
- throw new TypeError(`[MajikAPI] "${label}" must be one of: ${valid.join(", ")}. Received: ${JSON.stringify(value)}`);
42
- }
43
- }
44
- function assertBoolean(value, label) {
45
- if (typeof value !== "boolean") {
46
- throw new TypeError(`[MajikAPI] "${label}" must be a boolean. Received: ${JSON.stringify(value)}`);
47
- }
48
- }
49
- function assertStringArray(value, label) {
50
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
51
- throw new TypeError(`[MajikAPI] "${label}" must be an array of strings. Received: ${JSON.stringify(value)}`);
52
- }
53
- }
54
- function isValidIPv4(ip) {
55
- return (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip) &&
56
- ip.split(".").every((o) => parseInt(o) <= 255));
57
- }
58
- function isValidIPv6(ip) {
59
- return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(":");
60
- }
61
- function isValidCIDR(cidr) {
62
- const [ip, prefix] = cidr.split("/");
63
- if (!prefix)
64
- return false;
65
- const p = parseInt(prefix);
66
- return ((isValidIPv4(ip) && p >= 0 && p <= 32) ||
67
- (isValidIPv6(ip) && p >= 0 && p <= 128));
68
- }
69
- function validateIP(ip) {
70
- if (!isValidIPv4(ip) && !isValidIPv6(ip) && !isValidCIDR(ip)) {
71
- throw new Error(`[MajikAPI] Invalid IP address or CIDR: "${ip}"`);
72
- }
73
- }
74
- function isValidDomain(domain) {
75
- return (/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/.test(domain) || /^\*$/.test(domain));
76
- }
77
- function validateDomain(domain) {
78
- if (!isValidDomain(domain)) {
79
- throw new Error(`[MajikAPI] Invalid domain: "${domain}"`);
80
- }
81
- }
82
- function isValidISODate(value) {
83
- const d = new Date(value);
84
- return !isNaN(d.getTime());
85
- }
86
- // ─────────────────────────────────────────────
87
- // Default Settings Factory
88
- // ─────────────────────────────────────────────
89
- function buildDefaultSettings(overrides) {
90
- return {
91
- rateLimit: { ...DEFAULT_RATE_LIMIT, ...(overrides?.rateLimit ?? {}) },
92
- ipWhitelist: {
93
- enabled: false,
94
- addresses: [],
95
- ...(overrides?.ipWhitelist ?? {}),
96
- },
97
- domainWhitelist: {
98
- enabled: false,
99
- domains: [],
100
- ...(overrides?.domainWhitelist ?? {}),
101
- },
102
- allowedMethods: overrides?.allowedMethods ?? [],
103
- metadata: overrides?.metadata ?? {},
104
- };
105
- }
6
+ const VALID_QUOTA_FREQUENCIES = [
7
+ "hours",
8
+ "days",
9
+ "weeks",
10
+ "months",
11
+ "quarters",
12
+ "years",
13
+ ];
106
14
  // ─────────────────────────────────────────────
107
15
  // MajikAPI Class
108
16
  // ─────────────────────────────────────────────
@@ -201,6 +109,7 @@ export class MajikAPI {
201
109
  * Accepts the raw output of `toJSON()`, a Supabase row, or a Redis cache hit.
202
110
  *
203
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.
204
113
  */
205
114
  static fromJSON(data) {
206
115
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
@@ -237,6 +146,7 @@ export class MajikAPI {
237
146
  * Serialise to a plain JSON-safe object matching MajikAPIJSON.
238
147
  * Safe to store in Supabase or cache in Redis.
239
148
  * raw_api_key is NEVER included.
149
+ * is_valid is computed at serialisation time from isActive().
240
150
  */
241
151
  toJSON() {
242
152
  return {
@@ -247,6 +157,7 @@ export class MajikAPI {
247
157
  timestamp: this._timestamp.toISOString(),
248
158
  restricted: this._restricted,
249
159
  valid_until: this._valid_until ? this._valid_until.toISOString() : null,
160
+ is_valid: this.is_valid,
250
161
  settings: structuredClone(this._settings),
251
162
  };
252
163
  }
@@ -262,9 +173,6 @@ export class MajikAPI {
262
173
  assertString(this._owner_id, "owner_id");
263
174
  assertString(this._name, "name");
264
175
  assertString(this._api_key, "api_key");
265
- if (!/^[a-f0-9]{64}$/.test(this._api_key)) {
266
- throw new Error("[MajikAPI] validate(): 'api_key' does not appear to be a valid SHA-256 hash.");
267
- }
268
176
  if (!(this._timestamp instanceof Date) ||
269
177
  isNaN(this._timestamp.getTime())) {
270
178
  throw new TypeError("[MajikAPI] validate(): 'timestamp' is not a valid Date.");
@@ -319,10 +227,10 @@ export class MajikAPI {
319
227
  * (500 req/min). Attempting to set a higher rate will throw unless
320
228
  * bypassSafeLimit is explicitly passed as true.
321
229
  *
322
- * @param amount - Number of allowed requests per frequency window.
323
- * @param frequency - The time window unit.
230
+ * @param amount - Number of allowed requests per frequency window.
231
+ * @param frequency - The time window unit.
324
232
  * @param bypassSafeLimit - When true, skips the MAX_RATE_LIMIT ceiling check.
325
- * Defaults to false. Use with caution.
233
+ * Defaults to false. Use with caution.
326
234
  */
327
235
  setRateLimit(amount, frequency, bypassSafeLimit = false) {
328
236
  assertPositiveInteger(amount, "amount");
@@ -346,6 +254,70 @@ export class MajikAPI {
346
254
  this._settings.rateLimit = { ...DEFAULT_RATE_LIMIT };
347
255
  }
348
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
+ // ─────────────────────────────────────────────
349
321
  // Key Rotation
350
322
  // ─────────────────────────────────────────────
351
323
  /**
@@ -555,6 +527,16 @@ export class MajikAPI {
555
527
  get validUntil() {
556
528
  return this._valid_until ? new Date(this._valid_until) : null;
557
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
+ }
558
540
  /** Returns a deep clone — mutations to the returned object have no effect. */
559
541
  get settings() {
560
542
  return structuredClone(this._settings);
@@ -562,6 +544,31 @@ export class MajikAPI {
562
544
  get rateLimit() {
563
545
  return { ...this._settings.rateLimit };
564
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
+ }
565
572
  get ipWhitelist() {
566
573
  return structuredClone(this._settings.ipWhitelist);
567
574
  }
@@ -616,12 +623,33 @@ export class MajikAPI {
616
623
  }
617
624
  throw new TypeError(`[MajikAPI] "${label}" must be a Date instance or an ISO date string.`);
618
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
+ }
619
631
  static validateSettings(settings) {
620
632
  if (typeof settings !== "object" || settings === null) {
621
633
  throw new TypeError("[MajikAPI] 'settings' must be an object.");
622
634
  }
623
635
  assertPositiveInteger(settings.rateLimit?.amount, "settings.rateLimit.amount");
624
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
+ }
625
653
  assertBoolean(settings.ipWhitelist?.enabled, "settings.ipWhitelist.enabled");
626
654
  assertStringArray(settings.ipWhitelist?.addresses, "settings.ipWhitelist.addresses");
627
655
  settings.ipWhitelist.addresses.forEach((ip) => validateIP(ip));
@@ -633,7 +661,7 @@ export class MajikAPI {
633
661
  // Debug
634
662
  // ─────────────────────────────────────────────
635
663
  toString() {
636
- 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}]`;
637
665
  }
638
666
  [Symbol.for("nodejs.util.inspect.custom")]() {
639
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.d.ts CHANGED
@@ -1,6 +1,20 @@
1
+ import { MajikAPISettings, RateLimitFrequency } from "./types";
1
2
  export declare function sha256(input: string): string;
2
3
  export declare function arrayToBase64(data: Uint8Array): string;
3
4
  /**
4
5
  * Generate a Random v4 UUID
5
6
  */
6
7
  export declare function generateID(): string;
8
+ export declare function assertString(value: unknown, label: string): asserts value is string;
9
+ export declare function assertPositiveInteger(value: unknown, label: string): asserts value is number;
10
+ export declare function assertRateLimitFrequency(value: unknown, label: string): asserts value is RateLimitFrequency;
11
+ export declare function assertBoolean(value: unknown, label: string): asserts value is boolean;
12
+ export declare function assertStringArray(value: unknown, label: string): asserts value is string[];
13
+ export declare function isValidIPv4(ip: string): boolean;
14
+ export declare function isValidIPv6(ip: string): boolean;
15
+ export declare function isValidCIDR(cidr: string): boolean;
16
+ export declare function validateIP(ip: string): void;
17
+ export declare function isValidDomain(domain: string): boolean;
18
+ export declare function validateDomain(domain: string): void;
19
+ export declare function isValidISODate(value: string): boolean;
20
+ export declare function buildDefaultSettings(overrides?: Partial<MajikAPISettings>): MajikAPISettings;
package/dist/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { hash } from "@stablelib/sha256";
2
2
  import { v4 as uuidv4 } from "uuid";
3
+ import { DEFAULT_RATE_LIMIT } from "./constants";
3
4
  export function sha256(input) {
4
5
  const hashed = hash(new TextEncoder().encode(input));
5
6
  return arrayToBase64(hashed);
@@ -25,3 +26,85 @@ export function generateID() {
25
26
  throw new Error(`Failed to generate ID: ${error}`);
26
27
  }
27
28
  }
29
+ // ─────────────────────────────────────────────
30
+ // Validation Helpers
31
+ // ─────────────────────────────────────────────
32
+ export function assertString(value, label) {
33
+ if (typeof value !== "string" || value.trim() === "") {
34
+ throw new TypeError(`[MajikAPI] "${label}" must be a non-empty string. Received: ${JSON.stringify(value)}`);
35
+ }
36
+ }
37
+ export function assertPositiveInteger(value, label) {
38
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
39
+ throw new RangeError(`[MajikAPI] "${label}" must be a positive integer. Received: ${JSON.stringify(value)}`);
40
+ }
41
+ }
42
+ export function assertRateLimitFrequency(value, label) {
43
+ const valid = ["seconds", "minutes", "hours"];
44
+ if (!valid.includes(value)) {
45
+ throw new TypeError(`[MajikAPI] "${label}" must be one of: ${valid.join(", ")}. Received: ${JSON.stringify(value)}`);
46
+ }
47
+ }
48
+ export function assertBoolean(value, label) {
49
+ if (typeof value !== "boolean") {
50
+ throw new TypeError(`[MajikAPI] "${label}" must be a boolean. Received: ${JSON.stringify(value)}`);
51
+ }
52
+ }
53
+ export function assertStringArray(value, label) {
54
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
55
+ throw new TypeError(`[MajikAPI] "${label}" must be an array of strings. Received: ${JSON.stringify(value)}`);
56
+ }
57
+ }
58
+ export function isValidIPv4(ip) {
59
+ return (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip) &&
60
+ ip.split(".").every((o) => parseInt(o) <= 255));
61
+ }
62
+ export function isValidIPv6(ip) {
63
+ return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(":");
64
+ }
65
+ export function isValidCIDR(cidr) {
66
+ const [ip, prefix] = cidr.split("/");
67
+ if (!prefix)
68
+ return false;
69
+ const p = parseInt(prefix);
70
+ return ((isValidIPv4(ip) && p >= 0 && p <= 32) ||
71
+ (isValidIPv6(ip) && p >= 0 && p <= 128));
72
+ }
73
+ export function validateIP(ip) {
74
+ if (!isValidIPv4(ip) && !isValidIPv6(ip) && !isValidCIDR(ip)) {
75
+ throw new Error(`[MajikAPI] Invalid IP address or CIDR: "${ip}"`);
76
+ }
77
+ }
78
+ export function isValidDomain(domain) {
79
+ return (/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/.test(domain) || /^\*$/.test(domain));
80
+ }
81
+ export function validateDomain(domain) {
82
+ if (!isValidDomain(domain)) {
83
+ throw new Error(`[MajikAPI] Invalid domain: "${domain}"`);
84
+ }
85
+ }
86
+ export function isValidISODate(value) {
87
+ const d = new Date(value);
88
+ return !isNaN(d.getTime());
89
+ }
90
+ // ─────────────────────────────────────────────
91
+ // Default Settings Factory
92
+ // ─────────────────────────────────────────────
93
+ export function buildDefaultSettings(overrides) {
94
+ return {
95
+ rateLimit: { ...DEFAULT_RATE_LIMIT, ...(overrides?.rateLimit ?? {}) },
96
+ ipWhitelist: {
97
+ enabled: false,
98
+ addresses: [],
99
+ ...(overrides?.ipWhitelist ?? {}),
100
+ },
101
+ domainWhitelist: {
102
+ enabled: false,
103
+ domains: [],
104
+ ...(overrides?.domainWhitelist ?? {}),
105
+ },
106
+ allowedMethods: overrides?.allowedMethods ?? [],
107
+ metadata: overrides?.metadata ?? {},
108
+ quota: overrides?.quota ?? null,
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.0",
5
+ "version": "0.1.2",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Zelijah",
8
8
  "main": "./dist/index.js",