@munchi_oy/license-sdk 0.1.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 +29 -0
- package/dist/index.cjs +145 -0
- package/dist/index.d.cts +117 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +120 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @munchi_oy/license-sdk
|
|
2
|
+
|
|
3
|
+
Asks the Munchi license service what a venue may use. Zero dependencies, Node 20+, ESM and CommonJS.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { LicenseClient } from "@munchi_oy/license-sdk";
|
|
7
|
+
|
|
8
|
+
const license = new LicenseClient({
|
|
9
|
+
baseUrl: process.env.LICENSE_URL, // one-license-api-{dev,prod} URL
|
|
10
|
+
serviceKey: process.env.LICENSE_KEY, // your service's key from Secret Manager
|
|
11
|
+
mode: "report", // start here: never blocks, calls onDenied instead
|
|
12
|
+
onDenied: (e) => logger.warn(e, "license would deny"),
|
|
13
|
+
onError: (e) => logger.error(e, "license service unreachable"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (await license.can(venueId, "kiosk")) { /* … */ }
|
|
17
|
+
const kiosks = await license.limit(venueId, "kiosks"); // number, null = unlimited
|
|
18
|
+
const snapshot = await license.entitlements(venueId); // full snapshot, null = unknown venue
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Behaviour | Default |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Cache per venue, then revalidate with ETag (usually a 304) | `cacheTtlMs` 60 s, and always when the snapshot's `validUntil` passes |
|
|
24
|
+
| Service unreachable | keeps the last snapshot for `maxStaleMs` (24 h); with nothing cached, `can()` allows |
|
|
25
|
+
| Request timeout / retries | `timeoutMs` 2 s, one retry on network errors and 5xx |
|
|
26
|
+
| Concurrent calls for one venue | share one request |
|
|
27
|
+
| `mode: "enforce"` | `can()` returns `false` for features the venue doesn't have |
|
|
28
|
+
|
|
29
|
+
Call `invalidate(venueId)` when you receive a `license.changed` event.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
LicenseClient: () => LicenseClient
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/license-client.ts
|
|
29
|
+
var UNREACHABLE = /* @__PURE__ */ Symbol("unreachable");
|
|
30
|
+
var LicenseClient = class {
|
|
31
|
+
static {
|
|
32
|
+
__name(this, "LicenseClient");
|
|
33
|
+
}
|
|
34
|
+
cache = /* @__PURE__ */ new Map();
|
|
35
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
36
|
+
options;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = {
|
|
39
|
+
cacheTtlMs: 6e4,
|
|
40
|
+
maxStaleMs: 24 * 60 * 6e4,
|
|
41
|
+
timeoutMs: 2e3,
|
|
42
|
+
mode: "enforce",
|
|
43
|
+
fetch: globalThis.fetch,
|
|
44
|
+
...options,
|
|
45
|
+
baseUrl: options.baseUrl.replace(/\/+$/, "")
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The venue's snapshot, or `null` if the license service doesn't know the venue.
|
|
50
|
+
* `undefined` means the service couldn't be reached and nothing usable is cached.
|
|
51
|
+
*/
|
|
52
|
+
async entitlements(venueId) {
|
|
53
|
+
const cached = this.cache.get(venueId);
|
|
54
|
+
if (cached && this.isFresh(cached)) return cached.snapshot;
|
|
55
|
+
const refreshed = await this.refresh(venueId, cached);
|
|
56
|
+
if (refreshed !== UNREACHABLE) return refreshed.snapshot;
|
|
57
|
+
if (cached && Date.now() - cached.checkedAt < this.options.maxStaleMs) return cached.snapshot;
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
/** Never blocks a venue because the license service is down: unknown state allows. */
|
|
61
|
+
async can(venueId, feature) {
|
|
62
|
+
const snapshot = await this.entitlements(venueId);
|
|
63
|
+
if (snapshot === void 0) return true;
|
|
64
|
+
const allowed = snapshot?.features.includes(feature) ?? false;
|
|
65
|
+
if (allowed) return true;
|
|
66
|
+
this.options.onDenied?.({
|
|
67
|
+
venueId,
|
|
68
|
+
feature,
|
|
69
|
+
reason: snapshot === null ? "unknown_venue" : "not_licensed"
|
|
70
|
+
});
|
|
71
|
+
return this.options.mode === "report";
|
|
72
|
+
}
|
|
73
|
+
/** `null` = unlimited, `undefined` = no such limit or no snapshot. */
|
|
74
|
+
async limit(venueId, key) {
|
|
75
|
+
return (await this.entitlements(venueId))?.limits[key];
|
|
76
|
+
}
|
|
77
|
+
/** Drop the cached snapshot, e.g. after a `license.changed` event. */
|
|
78
|
+
invalidate(venueId) {
|
|
79
|
+
this.cache.delete(venueId);
|
|
80
|
+
}
|
|
81
|
+
isFresh(entry) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const expired = entry.snapshot !== null && now >= Date.parse(entry.snapshot.validUntil);
|
|
84
|
+
return !expired && now - entry.checkedAt < this.options.cacheTtlMs;
|
|
85
|
+
}
|
|
86
|
+
refresh(venueId, cached) {
|
|
87
|
+
const pending = this.inFlight.get(venueId);
|
|
88
|
+
if (pending) return pending;
|
|
89
|
+
const request = this.fetchEntry(venueId, cached).finally(() => this.inFlight.delete(venueId));
|
|
90
|
+
this.inFlight.set(venueId, request);
|
|
91
|
+
return request;
|
|
92
|
+
}
|
|
93
|
+
async fetchEntry(venueId, cached) {
|
|
94
|
+
const headers = {
|
|
95
|
+
authorization: `Bearer ${this.options.serviceKey}`
|
|
96
|
+
};
|
|
97
|
+
if (cached?.etag) headers["if-none-match"] = cached.etag;
|
|
98
|
+
const url = `${this.options.baseUrl}/v1/entitlements?venueId=${venueId}`;
|
|
99
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
100
|
+
try {
|
|
101
|
+
const response = await this.options.fetch(url, {
|
|
102
|
+
headers,
|
|
103
|
+
signal: AbortSignal.timeout(this.options.timeoutMs)
|
|
104
|
+
});
|
|
105
|
+
const entry = await this.toEntry(response, cached);
|
|
106
|
+
if (entry) {
|
|
107
|
+
this.cache.set(venueId, entry);
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
if (response.status < 500) {
|
|
111
|
+
this.report(new Error(`License service answered ${response.status} for venue ${venueId}`));
|
|
112
|
+
return UNREACHABLE;
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (attempt === 2) this.report(error instanceof Error ? error : new Error(String(error)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return UNREACHABLE;
|
|
119
|
+
}
|
|
120
|
+
async toEntry(response, cached) {
|
|
121
|
+
const checkedAt = Date.now();
|
|
122
|
+
if (response.status === 304 && cached) return {
|
|
123
|
+
...cached,
|
|
124
|
+
checkedAt
|
|
125
|
+
};
|
|
126
|
+
if (response.status === 404) return {
|
|
127
|
+
snapshot: null,
|
|
128
|
+
etag: null,
|
|
129
|
+
checkedAt
|
|
130
|
+
};
|
|
131
|
+
if (!response.ok) return null;
|
|
132
|
+
return {
|
|
133
|
+
snapshot: await response.json(),
|
|
134
|
+
etag: response.headers.get("etag"),
|
|
135
|
+
checkedAt
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
report(error) {
|
|
139
|
+
this.options.onError?.(error);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
143
|
+
0 && (module.exports = {
|
|
144
|
+
LicenseClient
|
|
145
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
type ProductCode = string;
|
|
2
|
+
type FeatureCode = string;
|
|
3
|
+
interface Rate {
|
|
4
|
+
bps: number;
|
|
5
|
+
fixedMinor: number;
|
|
6
|
+
}
|
|
7
|
+
interface Rates {
|
|
8
|
+
pos: Rate;
|
|
9
|
+
online: Rate;
|
|
10
|
+
kiosk: Rate;
|
|
11
|
+
includeTips: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type LicenseState = "trial" | "active" | "grace" | "start" | "suspended" | "terminated" | "canceled";
|
|
15
|
+
type PaymentMethodStatus = "ok" | "missing" | "failing";
|
|
16
|
+
type PaymentsMode = "munchi" | "external";
|
|
17
|
+
type PaymentsStatus = "not_started" | "submitted" | "pre_check" | "in_review" | "action_required" | "approved" | "live" | "rejected" | "restricted";
|
|
18
|
+
type SuspensionReason = "misconduct" | "fraud_suspected" | "chargebacks" | "legal_request" | "terms_breach" | "customer_request" | "other";
|
|
19
|
+
interface Suspension {
|
|
20
|
+
reason: SuspensionReason;
|
|
21
|
+
mode: "read_only" | "off";
|
|
22
|
+
since: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
type UsageState = "ok" | "warning" | "over" | "waiting_for_card" | "budget_reached";
|
|
26
|
+
interface SnapshotUsage {
|
|
27
|
+
used: number;
|
|
28
|
+
included: number | null;
|
|
29
|
+
percent: number | null;
|
|
30
|
+
state: UsageState;
|
|
31
|
+
overageUnits: number;
|
|
32
|
+
overageProduct: ProductCode | null;
|
|
33
|
+
overageCostMinor: number;
|
|
34
|
+
}
|
|
35
|
+
type PriceSource = "catalog" | "contract" | "license";
|
|
36
|
+
interface SnapshotItem {
|
|
37
|
+
productCode: ProductCode;
|
|
38
|
+
quantity: number;
|
|
39
|
+
priceMinor: number;
|
|
40
|
+
priceSource: PriceSource;
|
|
41
|
+
}
|
|
42
|
+
type RateSource = "catalog" | "contract" | "license";
|
|
43
|
+
interface Snapshot {
|
|
44
|
+
schemaVersion: number;
|
|
45
|
+
v: number;
|
|
46
|
+
catalogVersion: number;
|
|
47
|
+
catalogFrozen: boolean;
|
|
48
|
+
priceVersion: number;
|
|
49
|
+
account: string;
|
|
50
|
+
location: string;
|
|
51
|
+
venueId: number | null;
|
|
52
|
+
plan: ProductCode;
|
|
53
|
+
state: LicenseState;
|
|
54
|
+
items: SnapshotItem[];
|
|
55
|
+
features: FeatureCode[];
|
|
56
|
+
limits: Record<string, number | null>;
|
|
57
|
+
rates: Rates & {
|
|
58
|
+
source: RateSource;
|
|
59
|
+
};
|
|
60
|
+
settings: Record<string, boolean>;
|
|
61
|
+
usage: Record<string, SnapshotUsage>;
|
|
62
|
+
paymentMethod: PaymentMethodStatus;
|
|
63
|
+
paymentsMode: PaymentsMode;
|
|
64
|
+
paymentsStatus: PaymentsStatus;
|
|
65
|
+
spendingBudget: {
|
|
66
|
+
limitMinor: number | null;
|
|
67
|
+
usedMinor: number;
|
|
68
|
+
};
|
|
69
|
+
suspension: Suspension | null;
|
|
70
|
+
validUntil: string;
|
|
71
|
+
computedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface LicenseClientOptions {
|
|
75
|
+
/** e.g. `https://one-license-api-prod-749434767588.europe-west3.run.app` */
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
serviceKey: string;
|
|
78
|
+
/** How long a snapshot is used before asking the server again (an ETag check, usually a 304). */
|
|
79
|
+
cacheTtlMs?: number;
|
|
80
|
+
/** How long a snapshot keeps being used while the server can't be reached. */
|
|
81
|
+
maxStaleMs?: number;
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
/** `report`: `can()` always allows and calls `onDenied` for what `enforce` would block. */
|
|
84
|
+
mode?: "enforce" | "report";
|
|
85
|
+
onDenied?: (event: DeniedEvent) => void;
|
|
86
|
+
onError?: (error: Error) => void;
|
|
87
|
+
fetch?: typeof fetch;
|
|
88
|
+
}
|
|
89
|
+
interface DeniedEvent {
|
|
90
|
+
venueId: number;
|
|
91
|
+
feature: string;
|
|
92
|
+
reason: "not_licensed" | "unknown_venue";
|
|
93
|
+
}
|
|
94
|
+
declare class LicenseClient {
|
|
95
|
+
private readonly cache;
|
|
96
|
+
private readonly inFlight;
|
|
97
|
+
private readonly options;
|
|
98
|
+
constructor(options: LicenseClientOptions);
|
|
99
|
+
/**
|
|
100
|
+
* The venue's snapshot, or `null` if the license service doesn't know the venue.
|
|
101
|
+
* `undefined` means the service couldn't be reached and nothing usable is cached.
|
|
102
|
+
*/
|
|
103
|
+
entitlements(venueId: number): Promise<Snapshot | null | undefined>;
|
|
104
|
+
/** Never blocks a venue because the license service is down: unknown state allows. */
|
|
105
|
+
can(venueId: number, feature: string): Promise<boolean>;
|
|
106
|
+
/** `null` = unlimited, `undefined` = no such limit or no snapshot. */
|
|
107
|
+
limit(venueId: number, key: string): Promise<number | null | undefined>;
|
|
108
|
+
/** Drop the cached snapshot, e.g. after a `license.changed` event. */
|
|
109
|
+
invalidate(venueId: number): void;
|
|
110
|
+
private isFresh;
|
|
111
|
+
private refresh;
|
|
112
|
+
private fetchEntry;
|
|
113
|
+
private toEntry;
|
|
114
|
+
private report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { type DeniedEvent, LicenseClient, type LicenseClientOptions, type Snapshot };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
type ProductCode = string;
|
|
2
|
+
type FeatureCode = string;
|
|
3
|
+
interface Rate {
|
|
4
|
+
bps: number;
|
|
5
|
+
fixedMinor: number;
|
|
6
|
+
}
|
|
7
|
+
interface Rates {
|
|
8
|
+
pos: Rate;
|
|
9
|
+
online: Rate;
|
|
10
|
+
kiosk: Rate;
|
|
11
|
+
includeTips: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type LicenseState = "trial" | "active" | "grace" | "start" | "suspended" | "terminated" | "canceled";
|
|
15
|
+
type PaymentMethodStatus = "ok" | "missing" | "failing";
|
|
16
|
+
type PaymentsMode = "munchi" | "external";
|
|
17
|
+
type PaymentsStatus = "not_started" | "submitted" | "pre_check" | "in_review" | "action_required" | "approved" | "live" | "rejected" | "restricted";
|
|
18
|
+
type SuspensionReason = "misconduct" | "fraud_suspected" | "chargebacks" | "legal_request" | "terms_breach" | "customer_request" | "other";
|
|
19
|
+
interface Suspension {
|
|
20
|
+
reason: SuspensionReason;
|
|
21
|
+
mode: "read_only" | "off";
|
|
22
|
+
since: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
type UsageState = "ok" | "warning" | "over" | "waiting_for_card" | "budget_reached";
|
|
26
|
+
interface SnapshotUsage {
|
|
27
|
+
used: number;
|
|
28
|
+
included: number | null;
|
|
29
|
+
percent: number | null;
|
|
30
|
+
state: UsageState;
|
|
31
|
+
overageUnits: number;
|
|
32
|
+
overageProduct: ProductCode | null;
|
|
33
|
+
overageCostMinor: number;
|
|
34
|
+
}
|
|
35
|
+
type PriceSource = "catalog" | "contract" | "license";
|
|
36
|
+
interface SnapshotItem {
|
|
37
|
+
productCode: ProductCode;
|
|
38
|
+
quantity: number;
|
|
39
|
+
priceMinor: number;
|
|
40
|
+
priceSource: PriceSource;
|
|
41
|
+
}
|
|
42
|
+
type RateSource = "catalog" | "contract" | "license";
|
|
43
|
+
interface Snapshot {
|
|
44
|
+
schemaVersion: number;
|
|
45
|
+
v: number;
|
|
46
|
+
catalogVersion: number;
|
|
47
|
+
catalogFrozen: boolean;
|
|
48
|
+
priceVersion: number;
|
|
49
|
+
account: string;
|
|
50
|
+
location: string;
|
|
51
|
+
venueId: number | null;
|
|
52
|
+
plan: ProductCode;
|
|
53
|
+
state: LicenseState;
|
|
54
|
+
items: SnapshotItem[];
|
|
55
|
+
features: FeatureCode[];
|
|
56
|
+
limits: Record<string, number | null>;
|
|
57
|
+
rates: Rates & {
|
|
58
|
+
source: RateSource;
|
|
59
|
+
};
|
|
60
|
+
settings: Record<string, boolean>;
|
|
61
|
+
usage: Record<string, SnapshotUsage>;
|
|
62
|
+
paymentMethod: PaymentMethodStatus;
|
|
63
|
+
paymentsMode: PaymentsMode;
|
|
64
|
+
paymentsStatus: PaymentsStatus;
|
|
65
|
+
spendingBudget: {
|
|
66
|
+
limitMinor: number | null;
|
|
67
|
+
usedMinor: number;
|
|
68
|
+
};
|
|
69
|
+
suspension: Suspension | null;
|
|
70
|
+
validUntil: string;
|
|
71
|
+
computedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface LicenseClientOptions {
|
|
75
|
+
/** e.g. `https://one-license-api-prod-749434767588.europe-west3.run.app` */
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
serviceKey: string;
|
|
78
|
+
/** How long a snapshot is used before asking the server again (an ETag check, usually a 304). */
|
|
79
|
+
cacheTtlMs?: number;
|
|
80
|
+
/** How long a snapshot keeps being used while the server can't be reached. */
|
|
81
|
+
maxStaleMs?: number;
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
/** `report`: `can()` always allows and calls `onDenied` for what `enforce` would block. */
|
|
84
|
+
mode?: "enforce" | "report";
|
|
85
|
+
onDenied?: (event: DeniedEvent) => void;
|
|
86
|
+
onError?: (error: Error) => void;
|
|
87
|
+
fetch?: typeof fetch;
|
|
88
|
+
}
|
|
89
|
+
interface DeniedEvent {
|
|
90
|
+
venueId: number;
|
|
91
|
+
feature: string;
|
|
92
|
+
reason: "not_licensed" | "unknown_venue";
|
|
93
|
+
}
|
|
94
|
+
declare class LicenseClient {
|
|
95
|
+
private readonly cache;
|
|
96
|
+
private readonly inFlight;
|
|
97
|
+
private readonly options;
|
|
98
|
+
constructor(options: LicenseClientOptions);
|
|
99
|
+
/**
|
|
100
|
+
* The venue's snapshot, or `null` if the license service doesn't know the venue.
|
|
101
|
+
* `undefined` means the service couldn't be reached and nothing usable is cached.
|
|
102
|
+
*/
|
|
103
|
+
entitlements(venueId: number): Promise<Snapshot | null | undefined>;
|
|
104
|
+
/** Never blocks a venue because the license service is down: unknown state allows. */
|
|
105
|
+
can(venueId: number, feature: string): Promise<boolean>;
|
|
106
|
+
/** `null` = unlimited, `undefined` = no such limit or no snapshot. */
|
|
107
|
+
limit(venueId: number, key: string): Promise<number | null | undefined>;
|
|
108
|
+
/** Drop the cached snapshot, e.g. after a `license.changed` event. */
|
|
109
|
+
invalidate(venueId: number): void;
|
|
110
|
+
private isFresh;
|
|
111
|
+
private refresh;
|
|
112
|
+
private fetchEntry;
|
|
113
|
+
private toEntry;
|
|
114
|
+
private report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { type DeniedEvent, LicenseClient, type LicenseClientOptions, type Snapshot };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/license-client.ts
|
|
5
|
+
var UNREACHABLE = /* @__PURE__ */ Symbol("unreachable");
|
|
6
|
+
var LicenseClient = class {
|
|
7
|
+
static {
|
|
8
|
+
__name(this, "LicenseClient");
|
|
9
|
+
}
|
|
10
|
+
cache = /* @__PURE__ */ new Map();
|
|
11
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
12
|
+
options;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = {
|
|
15
|
+
cacheTtlMs: 6e4,
|
|
16
|
+
maxStaleMs: 24 * 60 * 6e4,
|
|
17
|
+
timeoutMs: 2e3,
|
|
18
|
+
mode: "enforce",
|
|
19
|
+
fetch: globalThis.fetch,
|
|
20
|
+
...options,
|
|
21
|
+
baseUrl: options.baseUrl.replace(/\/+$/, "")
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The venue's snapshot, or `null` if the license service doesn't know the venue.
|
|
26
|
+
* `undefined` means the service couldn't be reached and nothing usable is cached.
|
|
27
|
+
*/
|
|
28
|
+
async entitlements(venueId) {
|
|
29
|
+
const cached = this.cache.get(venueId);
|
|
30
|
+
if (cached && this.isFresh(cached)) return cached.snapshot;
|
|
31
|
+
const refreshed = await this.refresh(venueId, cached);
|
|
32
|
+
if (refreshed !== UNREACHABLE) return refreshed.snapshot;
|
|
33
|
+
if (cached && Date.now() - cached.checkedAt < this.options.maxStaleMs) return cached.snapshot;
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
/** Never blocks a venue because the license service is down: unknown state allows. */
|
|
37
|
+
async can(venueId, feature) {
|
|
38
|
+
const snapshot = await this.entitlements(venueId);
|
|
39
|
+
if (snapshot === void 0) return true;
|
|
40
|
+
const allowed = snapshot?.features.includes(feature) ?? false;
|
|
41
|
+
if (allowed) return true;
|
|
42
|
+
this.options.onDenied?.({
|
|
43
|
+
venueId,
|
|
44
|
+
feature,
|
|
45
|
+
reason: snapshot === null ? "unknown_venue" : "not_licensed"
|
|
46
|
+
});
|
|
47
|
+
return this.options.mode === "report";
|
|
48
|
+
}
|
|
49
|
+
/** `null` = unlimited, `undefined` = no such limit or no snapshot. */
|
|
50
|
+
async limit(venueId, key) {
|
|
51
|
+
return (await this.entitlements(venueId))?.limits[key];
|
|
52
|
+
}
|
|
53
|
+
/** Drop the cached snapshot, e.g. after a `license.changed` event. */
|
|
54
|
+
invalidate(venueId) {
|
|
55
|
+
this.cache.delete(venueId);
|
|
56
|
+
}
|
|
57
|
+
isFresh(entry) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const expired = entry.snapshot !== null && now >= Date.parse(entry.snapshot.validUntil);
|
|
60
|
+
return !expired && now - entry.checkedAt < this.options.cacheTtlMs;
|
|
61
|
+
}
|
|
62
|
+
refresh(venueId, cached) {
|
|
63
|
+
const pending = this.inFlight.get(venueId);
|
|
64
|
+
if (pending) return pending;
|
|
65
|
+
const request = this.fetchEntry(venueId, cached).finally(() => this.inFlight.delete(venueId));
|
|
66
|
+
this.inFlight.set(venueId, request);
|
|
67
|
+
return request;
|
|
68
|
+
}
|
|
69
|
+
async fetchEntry(venueId, cached) {
|
|
70
|
+
const headers = {
|
|
71
|
+
authorization: `Bearer ${this.options.serviceKey}`
|
|
72
|
+
};
|
|
73
|
+
if (cached?.etag) headers["if-none-match"] = cached.etag;
|
|
74
|
+
const url = `${this.options.baseUrl}/v1/entitlements?venueId=${venueId}`;
|
|
75
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await this.options.fetch(url, {
|
|
78
|
+
headers,
|
|
79
|
+
signal: AbortSignal.timeout(this.options.timeoutMs)
|
|
80
|
+
});
|
|
81
|
+
const entry = await this.toEntry(response, cached);
|
|
82
|
+
if (entry) {
|
|
83
|
+
this.cache.set(venueId, entry);
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
if (response.status < 500) {
|
|
87
|
+
this.report(new Error(`License service answered ${response.status} for venue ${venueId}`));
|
|
88
|
+
return UNREACHABLE;
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (attempt === 2) this.report(error instanceof Error ? error : new Error(String(error)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return UNREACHABLE;
|
|
95
|
+
}
|
|
96
|
+
async toEntry(response, cached) {
|
|
97
|
+
const checkedAt = Date.now();
|
|
98
|
+
if (response.status === 304 && cached) return {
|
|
99
|
+
...cached,
|
|
100
|
+
checkedAt
|
|
101
|
+
};
|
|
102
|
+
if (response.status === 404) return {
|
|
103
|
+
snapshot: null,
|
|
104
|
+
etag: null,
|
|
105
|
+
checkedAt
|
|
106
|
+
};
|
|
107
|
+
if (!response.ok) return null;
|
|
108
|
+
return {
|
|
109
|
+
snapshot: await response.json(),
|
|
110
|
+
etag: response.headers.get("etag"),
|
|
111
|
+
checkedAt
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
report(error) {
|
|
115
|
+
this.options.onError?.(error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
export {
|
|
119
|
+
LicenseClient
|
|
120
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@munchi_oy/license-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client for the Munchi license service: cached entitlements and feature checks per venue",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/gomunchi/one-license.git",
|
|
34
|
+
"directory": "packages/license-sdk"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@munchi_oy/license-core": "0.1.0",
|
|
38
|
+
"@swc/core": "1.16.12",
|
|
39
|
+
"@swc/jest": "0.2.39",
|
|
40
|
+
"@types/jest": "30.0.0",
|
|
41
|
+
"jest": "30.5.2",
|
|
42
|
+
"tsup": "8.5.1"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --tsconfig tsconfig.build.json --clean",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"test:unit": "jest --config jest.config.cjs"
|
|
48
|
+
}
|
|
49
|
+
}
|