@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.
- package/dist/constants.d.ts +11 -0
- package/dist/constants.js +23 -0
- package/dist/majik-api.d.ts +78 -12
- package/dist/majik-api.js +137 -109
- package/dist/types.d.ts +26 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.js +83 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/majik-api.d.ts
CHANGED
|
@@ -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
|
|
70
|
-
* @param frequency
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
323
|
-
* @param frequency
|
|
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
|
-
*
|
|
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.
|
|
5
|
+
"version": "0.1.2",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Zelijah",
|
|
8
8
|
"main": "./dist/index.js",
|