@tumbaland/backend-core 1.25.0 → 1.27.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/dist/entitlements/client.d.ts.map +1 -1
- package/dist/entitlements/client.js +17 -1
- package/dist/entitlements/client.js.map +1 -1
- package/dist/entitlements/index.d.ts +3 -2
- package/dist/entitlements/index.d.ts.map +1 -1
- package/dist/entitlements/index.js +5 -4
- package/dist/entitlements/index.js.map +1 -1
- package/dist/entitlements/middleware.d.ts +14 -3
- package/dist/entitlements/middleware.d.ts.map +1 -1
- package/dist/entitlements/middleware.js +32 -26
- package/dist/entitlements/middleware.js.map +1 -1
- package/dist/entitlements/reconcile.d.ts +74 -0
- package/dist/entitlements/reconcile.d.ts.map +1 -0
- package/dist/entitlements/reconcile.js +297 -0
- package/dist/entitlements/reconcile.js.map +1 -0
- package/dist/entitlements/types.d.ts +0 -8
- package/dist/entitlements/types.d.ts.map +1 -1
- package/dist/entitlements/usage.d.ts +1 -1
- package/dist/entitlements/usage.d.ts.map +1 -1
- package/dist/entitlements/usage.js +11 -20
- package/dist/entitlements/usage.js.map +1 -1
- package/dist/groups/client.d.ts +27 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/groups/client.js +153 -0
- package/dist/groups/client.js.map +1 -0
- package/dist/groups/index.d.ts +5 -0
- package/dist/groups/index.d.ts.map +1 -0
- package/dist/groups/index.js +10 -0
- package/dist/groups/index.js.map +1 -0
- package/dist/groups/subject.d.ts +30 -0
- package/dist/groups/subject.d.ts.map +1 -0
- package/dist/groups/subject.js +52 -0
- package/dist/groups/subject.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/entitlements/client.test.ts +30 -0
- package/src/entitlements/client.ts +18 -1
- package/src/entitlements/index.ts +4 -2
- package/src/entitlements/middleware.test.ts +47 -30
- package/src/entitlements/middleware.ts +40 -26
- package/src/entitlements/reconcile.test.ts +333 -0
- package/src/entitlements/reconcile.ts +384 -0
- package/src/entitlements/types.ts +0 -9
- package/src/entitlements/usage.test.ts +27 -31
- package/src/entitlements/usage.ts +12 -22
- package/src/groups/client.test.ts +215 -0
- package/src/groups/client.ts +182 -0
- package/src/groups/index.ts +5 -0
- package/src/groups/subject.test.ts +85 -0
- package/src/groups/subject.ts +50 -0
- package/src/index.ts +3 -0
- package/dist/entitlements/mode.d.ts +0 -10
- package/dist/entitlements/mode.d.ts.map +0 -1
- package/dist/entitlements/mode.js +0 -32
- package/dist/entitlements/mode.js.map +0 -1
- package/src/entitlements/mode.test.ts +0 -50
- package/src/entitlements/mode.ts +0 -28
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/entitlements/client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/entitlements/client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAuIvC;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAoB1E;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE1D;AAED,qFAAqF;AACrF,wBAAgB,sBAAsB,IAAI,IAAI,CAG7C"}
|
|
@@ -58,8 +58,24 @@ function fallbackEntitlements(email) {
|
|
|
58
58
|
stale: true
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Prefers the container-network address over the public hostname.
|
|
63
|
+
*
|
|
64
|
+
* `PAYMENT_API_URL` cannot simply be pointed inward: payment-front's entrypoint
|
|
65
|
+
* templates that same variable into the `config.json` the *browser* downloads,
|
|
66
|
+
* so a Docker-internal value there breaks the subscription and checkout pages.
|
|
67
|
+
* One variable, two consumers with incompatible needs — this is the server-side
|
|
68
|
+
* half of it.
|
|
69
|
+
*
|
|
70
|
+
* Falling back keeps local dev (where only `PAYMENT_API_URL` is set) and any
|
|
71
|
+
* un-migrated deployment working; it just takes the long way round, out through
|
|
72
|
+
* the proxy and back, carrying the internal token across the public edge.
|
|
73
|
+
*/
|
|
74
|
+
function internalPaymentUrl() {
|
|
75
|
+
return process.env.PAYMENT_INTERNAL_URL || process.env.PAYMENT_API_URL;
|
|
76
|
+
}
|
|
61
77
|
async function fetchEntitlements(email) {
|
|
62
|
-
const baseUrl =
|
|
78
|
+
const baseUrl = internalPaymentUrl();
|
|
63
79
|
const token = process.env.INTERNAL_SERVICE_TOKEN;
|
|
64
80
|
const serviceId = process.env.SERVICE_NAME || 'unknown-service';
|
|
65
81
|
if (!baseUrl || !token) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/entitlements/client.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/entitlements/client.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export { UNLIMITED, FALLBACK_PLAN, STAFF_PLAN, applyLimits, METER_KEYS, FEATURE_KEYS, METER_DEFINITIONS, FEATURE_DEFINITIONS, DEFAULT_PLAN_LIMITS, ENTITLEMENT_CATALOG, resolveLimits, normalizeLimits, meterPeriod, isMeterKey, isFeatureKey, fitsWithin } from './definitions';
|
|
2
2
|
export type { MeterKey, FeatureKey, MeterPeriod, MeterDefinition, FeatureDefinition, PlanLimits, PartialPlanLimits } from './definitions';
|
|
3
|
-
export type { Entitlements
|
|
4
|
-
export { getEntitlementsMode, resetEntitlementsModeWarning } from './mode';
|
|
3
|
+
export type { Entitlements } from './types';
|
|
5
4
|
export { UsageMeter, currentPeriod } from './UsageMeter';
|
|
6
5
|
export type { IUsageMeter } from './UsageMeter';
|
|
7
6
|
export { getUsage, getUsageSnapshot, checkQuota, consumeQuota, releaseQuota, setUsage } from './usage';
|
|
8
7
|
export type { QuotaRequest } from './usage';
|
|
8
|
+
export { reconcileUsageMeters, runUsageReconciliation, RECONCILED_METERS } from './reconcile';
|
|
9
|
+
export type { ReconcileOptions, ReconcileReport, MeterChange, OrphanedMeter } from './reconcile';
|
|
9
10
|
export { getEntitlements, invalidateEntitlements, clearEntitlementsCache } from './client';
|
|
10
11
|
export { loadEntitlements, requireEntitlement, requireQuota, retentionFloor } from './middleware';
|
|
11
12
|
export type { EntitlementOptions, QuotaOptions, SubjectResolver } from './middleware';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/entitlements/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,UAAU,EACV,WAAW,EACX,UAAU,EACV,YAAY,EACZ,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,WAAW,EACX,UAAU,EACV,YAAY,EACZ,UAAU,EACX,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,QAAQ,EACR,UAAU,EACV,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,iBAAiB,EAClB,MAAM,eAAe,CAAC;AAEvB,YAAY,EAAE,YAAY,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/entitlements/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,UAAU,EACV,WAAW,EACX,UAAU,EACV,YAAY,EACZ,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,WAAW,EACX,UAAU,EACV,YAAY,EACZ,UAAU,EACX,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,QAAQ,EACR,UAAU,EACV,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,iBAAiB,EAClB,MAAM,eAAe,CAAC;AAEvB,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzD,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACvG,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC9F,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjG,OAAO,EAAE,eAAe,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAE3F,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAClG,YAAY,EAAE,kBAAkB,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.retentionFloor = exports.requireQuota = exports.requireEntitlement = exports.loadEntitlements = exports.clearEntitlementsCache = exports.invalidateEntitlements = exports.getEntitlements = exports.
|
|
3
|
+
exports.retentionFloor = exports.requireQuota = exports.requireEntitlement = exports.loadEntitlements = exports.clearEntitlementsCache = exports.invalidateEntitlements = exports.getEntitlements = exports.RECONCILED_METERS = exports.runUsageReconciliation = exports.reconcileUsageMeters = exports.setUsage = exports.releaseQuota = exports.consumeQuota = exports.checkQuota = exports.getUsageSnapshot = exports.getUsage = exports.currentPeriod = exports.UsageMeter = exports.fitsWithin = exports.isFeatureKey = exports.isMeterKey = exports.meterPeriod = exports.normalizeLimits = exports.resolveLimits = exports.ENTITLEMENT_CATALOG = exports.DEFAULT_PLAN_LIMITS = exports.FEATURE_DEFINITIONS = exports.METER_DEFINITIONS = exports.FEATURE_KEYS = exports.METER_KEYS = exports.applyLimits = exports.STAFF_PLAN = exports.FALLBACK_PLAN = exports.UNLIMITED = void 0;
|
|
4
4
|
var definitions_1 = require("./definitions");
|
|
5
5
|
Object.defineProperty(exports, "UNLIMITED", { enumerable: true, get: function () { return definitions_1.UNLIMITED; } });
|
|
6
6
|
Object.defineProperty(exports, "FALLBACK_PLAN", { enumerable: true, get: function () { return definitions_1.FALLBACK_PLAN; } });
|
|
@@ -18,9 +18,6 @@ Object.defineProperty(exports, "meterPeriod", { enumerable: true, get: function
|
|
|
18
18
|
Object.defineProperty(exports, "isMeterKey", { enumerable: true, get: function () { return definitions_1.isMeterKey; } });
|
|
19
19
|
Object.defineProperty(exports, "isFeatureKey", { enumerable: true, get: function () { return definitions_1.isFeatureKey; } });
|
|
20
20
|
Object.defineProperty(exports, "fitsWithin", { enumerable: true, get: function () { return definitions_1.fitsWithin; } });
|
|
21
|
-
var mode_1 = require("./mode");
|
|
22
|
-
Object.defineProperty(exports, "getEntitlementsMode", { enumerable: true, get: function () { return mode_1.getEntitlementsMode; } });
|
|
23
|
-
Object.defineProperty(exports, "resetEntitlementsModeWarning", { enumerable: true, get: function () { return mode_1.resetEntitlementsModeWarning; } });
|
|
24
21
|
var UsageMeter_1 = require("./UsageMeter");
|
|
25
22
|
Object.defineProperty(exports, "UsageMeter", { enumerable: true, get: function () { return UsageMeter_1.UsageMeter; } });
|
|
26
23
|
Object.defineProperty(exports, "currentPeriod", { enumerable: true, get: function () { return UsageMeter_1.currentPeriod; } });
|
|
@@ -31,6 +28,10 @@ Object.defineProperty(exports, "checkQuota", { enumerable: true, get: function (
|
|
|
31
28
|
Object.defineProperty(exports, "consumeQuota", { enumerable: true, get: function () { return usage_1.consumeQuota; } });
|
|
32
29
|
Object.defineProperty(exports, "releaseQuota", { enumerable: true, get: function () { return usage_1.releaseQuota; } });
|
|
33
30
|
Object.defineProperty(exports, "setUsage", { enumerable: true, get: function () { return usage_1.setUsage; } });
|
|
31
|
+
var reconcile_1 = require("./reconcile");
|
|
32
|
+
Object.defineProperty(exports, "reconcileUsageMeters", { enumerable: true, get: function () { return reconcile_1.reconcileUsageMeters; } });
|
|
33
|
+
Object.defineProperty(exports, "runUsageReconciliation", { enumerable: true, get: function () { return reconcile_1.runUsageReconciliation; } });
|
|
34
|
+
Object.defineProperty(exports, "RECONCILED_METERS", { enumerable: true, get: function () { return reconcile_1.RECONCILED_METERS; } });
|
|
34
35
|
var client_1 = require("./client");
|
|
35
36
|
Object.defineProperty(exports, "getEntitlements", { enumerable: true, get: function () { return client_1.getEntitlements; } });
|
|
36
37
|
Object.defineProperty(exports, "invalidateEntitlements", { enumerable: true, get: function () { return client_1.invalidateEntitlements; } });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/entitlements/index.ts"],"names":[],"mappings":";;;AAAA,6CAiBuB;AAhBrB,wGAAA,SAAS,OAAA;AACT,4GAAA,aAAa,OAAA;AACb,yGAAA,UAAU,OAAA;AACV,0GAAA,WAAW,OAAA;AACX,yGAAA,UAAU,OAAA;AACV,2GAAA,YAAY,OAAA;AACZ,gHAAA,iBAAiB,OAAA;AACjB,kHAAA,mBAAmB,OAAA;AACnB,kHAAA,mBAAmB,OAAA;AACnB,kHAAA,mBAAmB,OAAA;AACnB,4GAAA,aAAa,OAAA;AACb,8GAAA,eAAe,OAAA;AACf,0GAAA,WAAW,OAAA;AACX,yGAAA,UAAU,OAAA;AACV,2GAAA,YAAY,OAAA;AACZ,yGAAA,UAAU,OAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/entitlements/index.ts"],"names":[],"mappings":";;;AAAA,6CAiBuB;AAhBrB,wGAAA,SAAS,OAAA;AACT,4GAAA,aAAa,OAAA;AACb,yGAAA,UAAU,OAAA;AACV,0GAAA,WAAW,OAAA;AACX,yGAAA,UAAU,OAAA;AACV,2GAAA,YAAY,OAAA;AACZ,gHAAA,iBAAiB,OAAA;AACjB,kHAAA,mBAAmB,OAAA;AACnB,kHAAA,mBAAmB,OAAA;AACnB,kHAAA,mBAAmB,OAAA;AACnB,4GAAA,aAAa,OAAA;AACb,8GAAA,eAAe,OAAA;AACf,0GAAA,WAAW,OAAA;AACX,yGAAA,UAAU,OAAA;AACV,2GAAA,YAAY,OAAA;AACZ,yGAAA,UAAU,OAAA;AAcZ,2CAAyD;AAAhD,wGAAA,UAAU,OAAA;AAAE,2GAAA,aAAa,OAAA;AAElC,iCAAuG;AAA9F,iGAAA,QAAQ,OAAA;AAAE,yGAAA,gBAAgB,OAAA;AAAE,mGAAA,UAAU,OAAA;AAAE,qGAAA,YAAY,OAAA;AAAE,qGAAA,YAAY,OAAA;AAAE,iGAAA,QAAQ,OAAA;AAGrF,yCAA8F;AAArF,iHAAA,oBAAoB,OAAA;AAAE,mHAAA,sBAAsB,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAGxE,mCAA2F;AAAlF,yGAAA,eAAe,OAAA;AAAE,gHAAA,sBAAsB,OAAA;AAAE,gHAAA,sBAAsB,OAAA;AAExE,2CAAkG;AAAzF,8GAAA,gBAAgB,OAAA;AAAE,gHAAA,kBAAkB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,4GAAA,cAAc,OAAA"}
|
|
@@ -43,12 +43,23 @@ export interface QuotaOptions extends EntitlementOptions {
|
|
|
43
43
|
*/
|
|
44
44
|
export declare function requireQuota(meter: MeterKey, options?: QuotaOptions): RequestHandler;
|
|
45
45
|
/**
|
|
46
|
-
* The oldest timestamp a
|
|
46
|
+
* The oldest timestamp a caller may read, or `null` for unlimited history.
|
|
47
47
|
*
|
|
48
|
-
* Meant to be dropped straight into
|
|
48
|
+
* Meant to be dropped straight into a `$match` or a `find` filter — this is what
|
|
49
49
|
* gates the portfolio planner, since the planner runs entirely client-side and
|
|
50
50
|
* any `<PaywallGate>` around it is bypassable from devtools. Truncating the
|
|
51
51
|
* price history it depends on is not.
|
|
52
|
+
*
|
|
53
|
+
* **Several subjects resolve to the most generous floor.** Retention is not a
|
|
54
|
+
* counter, so unlike a quota it has no single payer: data held in a group is
|
|
55
|
+
* read by every member, and if it resolved to the *caller's* plan alone, a free
|
|
56
|
+
* member of a paid Family group would see ninety days of a household's history
|
|
57
|
+
* that the household paid to keep. Pass the caller together with the owners of
|
|
58
|
+
* the groups being read, and the group's plan lifts the floor for everyone in
|
|
59
|
+
* it — which is what "shared across a household" was sold as.
|
|
60
|
+
*
|
|
61
|
+
* An empty list resolves to the free plan's floor rather than to `null`: no
|
|
62
|
+
* identifiable subject must never mean unlimited history.
|
|
52
63
|
*/
|
|
53
|
-
export declare function retentionFloor(
|
|
64
|
+
export declare function retentionFloor(subject: string | readonly string[], now?: Date): Promise<Date | null>;
|
|
54
65
|
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAGlD,OAAO,
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAGlD,OAAO,EAAiB,UAAU,EAAE,QAAQ,EAA4B,MAAM,eAAe,CAAC;AAG9F,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,uGAAuG;YACvG,YAAY,CAAC,EAAE,YAAY,CAAC;SAC7B;KACF;CACF;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjG,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,eAAe,CAAC;CAC3B;AAID;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,eAAgC,GAAG,OAAO,CAAC,YAAY,CAAC,CASrH;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,GAAE,kBAAuB,GAAG,cAAc,CAyBxG;AAED,MAAM,WAAW,YAAa,SAAQ,kBAAkB;IACtD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,GAAE,YAAiB,GAAG,cAAc,CAkBxF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EACnC,GAAG,GAAE,IAAiB,GACrB,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAiBtB"}
|
|
@@ -12,7 +12,6 @@ const HttpError_1 = require("../errors/HttpError");
|
|
|
12
12
|
const definitions_1 = require("./definitions");
|
|
13
13
|
const client_1 = require("./client");
|
|
14
14
|
const usage_1 = require("./usage");
|
|
15
|
-
const mode_1 = require("./mode");
|
|
16
15
|
const defaultSubject = req => req.user?.email;
|
|
17
16
|
/**
|
|
18
17
|
* Resolves and caches entitlements on the request. Two checks on one route
|
|
@@ -35,23 +34,17 @@ async function loadEntitlements(req, resolve = defaultSubject) {
|
|
|
35
34
|
function requireEntitlement(feature, options = {}) {
|
|
36
35
|
return async (req, _res, next) => {
|
|
37
36
|
try {
|
|
38
|
-
const mode = (0, mode_1.getEntitlementsMode)();
|
|
39
|
-
if (mode === 'off')
|
|
40
|
-
return next();
|
|
41
37
|
const entitlements = await loadEntitlements(req, options.subject);
|
|
42
38
|
if (entitlements.limits.features[feature])
|
|
43
39
|
return next();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
return next();
|
|
54
|
-
}
|
|
40
|
+
logger_1.default.info('entitlement.blocked', {
|
|
41
|
+
event: 'entitlement.blocked',
|
|
42
|
+
feature,
|
|
43
|
+
subjectId: entitlements.email,
|
|
44
|
+
planCode: entitlements.planCode,
|
|
45
|
+
route: req.originalUrl,
|
|
46
|
+
stale: entitlements.stale === true
|
|
47
|
+
});
|
|
55
48
|
throw new HttpError_1.PaymentRequiredError(`Your plan does not include ${feature}`, {
|
|
56
49
|
code: 'FEATURE_NOT_IN_PLAN',
|
|
57
50
|
feature,
|
|
@@ -71,8 +64,6 @@ function requireEntitlement(feature, options = {}) {
|
|
|
71
64
|
function requireQuota(meter, options = {}) {
|
|
72
65
|
return async (req, _res, next) => {
|
|
73
66
|
try {
|
|
74
|
-
if ((0, mode_1.getEntitlementsMode)() === 'off')
|
|
75
|
-
return next();
|
|
76
67
|
const entitlements = await loadEntitlements(req, options.subject);
|
|
77
68
|
const amount = typeof options.amount === 'function' ? options.amount(req) : (options.amount ?? 1);
|
|
78
69
|
await (0, usage_1.checkQuota)({
|
|
@@ -90,20 +81,35 @@ function requireQuota(meter, options = {}) {
|
|
|
90
81
|
};
|
|
91
82
|
}
|
|
92
83
|
/**
|
|
93
|
-
* The oldest timestamp a
|
|
84
|
+
* The oldest timestamp a caller may read, or `null` for unlimited history.
|
|
94
85
|
*
|
|
95
|
-
* Meant to be dropped straight into
|
|
86
|
+
* Meant to be dropped straight into a `$match` or a `find` filter — this is what
|
|
96
87
|
* gates the portfolio planner, since the planner runs entirely client-side and
|
|
97
88
|
* any `<PaywallGate>` around it is bypassable from devtools. Truncating the
|
|
98
89
|
* price history it depends on is not.
|
|
90
|
+
*
|
|
91
|
+
* **Several subjects resolve to the most generous floor.** Retention is not a
|
|
92
|
+
* counter, so unlike a quota it has no single payer: data held in a group is
|
|
93
|
+
* read by every member, and if it resolved to the *caller's* plan alone, a free
|
|
94
|
+
* member of a paid Family group would see ninety days of a household's history
|
|
95
|
+
* that the household paid to keep. Pass the caller together with the owners of
|
|
96
|
+
* the groups being read, and the group's plan lifts the floor for everyone in
|
|
97
|
+
* it — which is what "shared across a household" was sold as.
|
|
98
|
+
*
|
|
99
|
+
* An empty list resolves to the free plan's floor rather than to `null`: no
|
|
100
|
+
* identifiable subject must never mean unlimited history.
|
|
99
101
|
*/
|
|
100
|
-
async function retentionFloor(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
async function retentionFloor(subject, now = new Date()) {
|
|
103
|
+
const emails = (Array.isArray(subject) ? subject : [subject]).filter(Boolean);
|
|
104
|
+
const floorFor = (days) => days === definitions_1.UNLIMITED || days < 0 ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
105
|
+
if (emails.length === 0) {
|
|
106
|
+
return floorFor((0, definitions_1.resolveLimits)(definitions_1.FALLBACK_PLAN).retentionDays);
|
|
107
|
+
}
|
|
108
|
+
const floors = await Promise.all(emails.map(async (email) => floorFor((await (0, client_1.getEntitlements)(email)).limits.retentionDays)));
|
|
109
|
+
// `null` is unlimited, which beats every date; otherwise the earliest floor
|
|
110
|
+
// is the one that reaches furthest back.
|
|
111
|
+
if (floors.some(floor => floor === null))
|
|
106
112
|
return null;
|
|
107
|
-
return
|
|
113
|
+
return floors.reduce((a, b) => (a < b ? a : b));
|
|
108
114
|
}
|
|
109
115
|
//# sourceMappingURL=middleware.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { MeterKey } from './definitions';
|
|
2
|
+
/**
|
|
3
|
+
* Rebuild the cumulative usage meters from the data they describe.
|
|
4
|
+
*
|
|
5
|
+
* **Why this can exist at all:** every meter reconciled here is *derived*.
|
|
6
|
+
* `storageBytes` is the sum of photo sizes, `trackedTickers` the count of
|
|
7
|
+
* active symbols, `seats` the members of the groups a subject owns — in each
|
|
8
|
+
* case the underlying collection is the record and the counter is only a cache
|
|
9
|
+
* of it, kept hot because a quota check cannot afford the aggregation. So the
|
|
10
|
+
* counter can always be recomputed, and reconciliation is cache invalidation.
|
|
11
|
+
*
|
|
12
|
+
* `aiJobs` is deliberately absent, and its absence is the same rule read the
|
|
13
|
+
* other way: nothing records that a GPU job happened except the counter itself,
|
|
14
|
+
* so there is no truth to recompute it from. That is precisely why
|
|
15
|
+
* `consumeQuota` rolls its increment back on rejection rather than leaving it —
|
|
16
|
+
* a leaked increment on that meter is permanent, where here it is temporary.
|
|
17
|
+
*
|
|
18
|
+
* **On racing with live writes:** a `consumeQuota` landing between the
|
|
19
|
+
* aggregation and the write is lost. That is tolerable for exactly the reason
|
|
20
|
+
* above — the next run re-derives the same number from source and converges —
|
|
21
|
+
* so this is scheduled at a quiet hour rather than defended with a lock.
|
|
22
|
+
*
|
|
23
|
+
* Reads collections owned by other services through the raw driver rather than
|
|
24
|
+
* mongoose models. They are not this library's documents to model, every
|
|
25
|
+
* service shares one database, and going through the driver avoids registering
|
|
26
|
+
* a second model for a schema that already exists elsewhere.
|
|
27
|
+
*/
|
|
28
|
+
/** Meters whose value can be recomputed from source data. See the note above. */
|
|
29
|
+
export declare const RECONCILED_METERS: readonly MeterKey[];
|
|
30
|
+
export interface ReconcileOptions {
|
|
31
|
+
/** Write the corrections. Default false — the caller must opt in to mutating. */
|
|
32
|
+
apply?: boolean;
|
|
33
|
+
/** Restrict to a single subject, for a targeted repair. */
|
|
34
|
+
subject?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface MeterChange {
|
|
37
|
+
subjectId: string;
|
|
38
|
+
meter: MeterKey;
|
|
39
|
+
/** `null` when no counter existed yet. */
|
|
40
|
+
from: number | null;
|
|
41
|
+
to: number;
|
|
42
|
+
}
|
|
43
|
+
export interface OrphanedMeter {
|
|
44
|
+
subjectId: string;
|
|
45
|
+
meter: string;
|
|
46
|
+
count: number;
|
|
47
|
+
}
|
|
48
|
+
export interface ReconcileReport {
|
|
49
|
+
applied: boolean;
|
|
50
|
+
scannedUsers: number;
|
|
51
|
+
/** Subjects with any attributable usage, before any `subject` filter. */
|
|
52
|
+
subjects: number;
|
|
53
|
+
expected: Record<string, Record<MeterKey, number>>;
|
|
54
|
+
changes: MeterChange[];
|
|
55
|
+
written: number;
|
|
56
|
+
orphanedMeters: OrphanedMeter[];
|
|
57
|
+
warnings: string[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Recompute every derived meter and, with `apply`, write back the ones that
|
|
61
|
+
* disagree.
|
|
62
|
+
*
|
|
63
|
+
* Idempotent: it computes absolute values and writes only differences, so a
|
|
64
|
+
* second run in a row is a no-op.
|
|
65
|
+
*/
|
|
66
|
+
export declare function reconcileUsageMeters(options?: ReconcileOptions): Promise<ReconcileReport>;
|
|
67
|
+
/**
|
|
68
|
+
* Run a reconciliation and log the outcome. The scheduled entry point.
|
|
69
|
+
*
|
|
70
|
+
* Never throws: this runs from a cron callback with no caller to catch it, and
|
|
71
|
+
* an unhandled rejection there takes the process down over a housekeeping job.
|
|
72
|
+
*/
|
|
73
|
+
export declare function runUsageReconciliation(): Promise<ReconcileReport | null>;
|
|
74
|
+
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/entitlements/reconcile.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,EAAE,SAAS,QAAQ,EAAgD,CAAC;AAElG,MAAM,WAAW,gBAAgB;IAC/B,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,QAAQ,CAAC;IAChB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AA2LD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CAkFnG;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAiC9E"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RECONCILED_METERS = void 0;
|
|
7
|
+
exports.reconcileUsageMeters = reconcileUsageMeters;
|
|
8
|
+
exports.runUsageReconciliation = runUsageReconciliation;
|
|
9
|
+
const mongoose_1 = __importDefault(require("mongoose"));
|
|
10
|
+
const logger_1 = __importDefault(require("../logging/logger"));
|
|
11
|
+
const UsageMeter_1 = require("./UsageMeter");
|
|
12
|
+
/**
|
|
13
|
+
* Rebuild the cumulative usage meters from the data they describe.
|
|
14
|
+
*
|
|
15
|
+
* **Why this can exist at all:** every meter reconciled here is *derived*.
|
|
16
|
+
* `storageBytes` is the sum of photo sizes, `trackedTickers` the count of
|
|
17
|
+
* active symbols, `seats` the members of the groups a subject owns — in each
|
|
18
|
+
* case the underlying collection is the record and the counter is only a cache
|
|
19
|
+
* of it, kept hot because a quota check cannot afford the aggregation. So the
|
|
20
|
+
* counter can always be recomputed, and reconciliation is cache invalidation.
|
|
21
|
+
*
|
|
22
|
+
* `aiJobs` is deliberately absent, and its absence is the same rule read the
|
|
23
|
+
* other way: nothing records that a GPU job happened except the counter itself,
|
|
24
|
+
* so there is no truth to recompute it from. That is precisely why
|
|
25
|
+
* `consumeQuota` rolls its increment back on rejection rather than leaving it —
|
|
26
|
+
* a leaked increment on that meter is permanent, where here it is temporary.
|
|
27
|
+
*
|
|
28
|
+
* **On racing with live writes:** a `consumeQuota` landing between the
|
|
29
|
+
* aggregation and the write is lost. That is tolerable for exactly the reason
|
|
30
|
+
* above — the next run re-derives the same number from source and converges —
|
|
31
|
+
* so this is scheduled at a quiet hour rather than defended with a lock.
|
|
32
|
+
*
|
|
33
|
+
* Reads collections owned by other services through the raw driver rather than
|
|
34
|
+
* mongoose models. They are not this library's documents to model, every
|
|
35
|
+
* service shares one database, and going through the driver avoids registering
|
|
36
|
+
* a second model for a schema that already exists elsewhere.
|
|
37
|
+
*/
|
|
38
|
+
/** Meters whose value can be recomputed from source data. See the note above. */
|
|
39
|
+
exports.RECONCILED_METERS = ['storageBytes', 'trackedTickers', 'seats'];
|
|
40
|
+
const normalizeEmail = (value) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Whoever currently holds the `owner` role — that is what group-service
|
|
43
|
+
* authorizes against, and `updateMemberRole` can move it while `createdBy`
|
|
44
|
+
* stays as first written. Several owners resolve to the lowest email so that a
|
|
45
|
+
* re-run bills the same person twice rather than two people once.
|
|
46
|
+
*/
|
|
47
|
+
function groupOwner(group, warnings) {
|
|
48
|
+
const owners = (group.members ?? [])
|
|
49
|
+
.filter(m => m.role === 'owner')
|
|
50
|
+
.map(m => normalizeEmail(m.email))
|
|
51
|
+
.filter((e) => Boolean(e))
|
|
52
|
+
.sort();
|
|
53
|
+
if (owners.length === 1)
|
|
54
|
+
return owners[0];
|
|
55
|
+
if (owners.length === 0) {
|
|
56
|
+
const fallback = normalizeEmail(group.createdBy);
|
|
57
|
+
warnings.push(`group ${group._id} ("${group.name}") has no member with role 'owner'; billing to createdBy=${fallback ?? 'MISSING'}`);
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
warnings.push(`group ${group._id} ("${group.name}") has ${owners.length} owners (${owners.join(', ')}); billing to ${owners[0]}`);
|
|
61
|
+
return owners[0];
|
|
62
|
+
}
|
|
63
|
+
async function computeExpected() {
|
|
64
|
+
const db = mongoose_1.default.connection.db;
|
|
65
|
+
if (!db)
|
|
66
|
+
throw new Error('Cannot reconcile usage meters: no active database connection');
|
|
67
|
+
const warnings = [];
|
|
68
|
+
// Album.userId / TrackedTicker.userId hold `req.user.id`, which is the
|
|
69
|
+
// auth-service User._id as a string, while a meter's subjectId is an email.
|
|
70
|
+
// Nothing personal can be attributed without coming back through this map.
|
|
71
|
+
const users = await db.collection('users').find({}, { projection: { email: 1 } }).toArray();
|
|
72
|
+
const userIdToEmail = new Map();
|
|
73
|
+
for (const user of users) {
|
|
74
|
+
const email = normalizeEmail(user.email);
|
|
75
|
+
if (email)
|
|
76
|
+
userIdToEmail.set(String(user._id), email);
|
|
77
|
+
}
|
|
78
|
+
const groups = (await db.collection('groups').find({}).toArray());
|
|
79
|
+
const groupsById = new Map();
|
|
80
|
+
for (const group of groups) {
|
|
81
|
+
groupsById.set(String(group._id), {
|
|
82
|
+
doc: group,
|
|
83
|
+
owner: groupOwner(group, warnings),
|
|
84
|
+
deleted: group.deletedAt !== null && group.deletedAt !== undefined
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const expected = new Map();
|
|
88
|
+
const bucket = (email) => {
|
|
89
|
+
let existing = expected.get(email);
|
|
90
|
+
if (!existing) {
|
|
91
|
+
existing = { storageBytes: 0, trackedTickers: new Set(), seats: new Set() };
|
|
92
|
+
expected.set(email, existing);
|
|
93
|
+
}
|
|
94
|
+
return existing;
|
|
95
|
+
};
|
|
96
|
+
// Every known user gets a bucket, so a user whose real usage is zero can
|
|
97
|
+
// still correct a stale non-zero counter back down.
|
|
98
|
+
for (const email of userIdToEmail.values())
|
|
99
|
+
bucket(email);
|
|
100
|
+
/**
|
|
101
|
+
* Group rows bill to the group owner: Family sells a pooled allowance, and
|
|
102
|
+
* charging the uploader would spend their personal free tier on it instead.
|
|
103
|
+
*/
|
|
104
|
+
const subjectFor = (row, label) => {
|
|
105
|
+
const groupId = row.groupId ? String(row.groupId) : undefined;
|
|
106
|
+
if (groupId) {
|
|
107
|
+
const group = groupsById.get(groupId);
|
|
108
|
+
if (group?.owner)
|
|
109
|
+
return group.owner;
|
|
110
|
+
// The group is gone but its rows are not. The uploader is the only
|
|
111
|
+
// identity left, and dropping the bytes would understate real storage.
|
|
112
|
+
const uploader = userIdToEmail.get(String(row.userId));
|
|
113
|
+
warnings.push(`${label} ${row._id} references missing group ${groupId}; billing to uploader=${uploader ?? 'UNATTRIBUTED'}`);
|
|
114
|
+
return uploader;
|
|
115
|
+
}
|
|
116
|
+
const owner = userIdToEmail.get(String(row.userId));
|
|
117
|
+
if (!owner)
|
|
118
|
+
warnings.push(`${label} ${row._id} has userId=${row.userId}, which matches no user; UNATTRIBUTED`);
|
|
119
|
+
return owner;
|
|
120
|
+
};
|
|
121
|
+
// --- storageBytes ---------------------------------------------------------
|
|
122
|
+
// Summed from the photos themselves rather than from the denormalized
|
|
123
|
+
// Album.totalSize: that counter is what a drifting $inc would have corrupted,
|
|
124
|
+
// so reconciling against it would re-import the drift instead of fixing it.
|
|
125
|
+
const photoTotals = await db
|
|
126
|
+
.collection('photos')
|
|
127
|
+
.aggregate([{ $group: { _id: '$albumId', totalSize: { $sum: '$size' }, photoCount: { $sum: 1 } } }], { allowDiskUse: true })
|
|
128
|
+
.toArray();
|
|
129
|
+
const sizeByAlbum = new Map(photoTotals.map(row => [String(row._id), row.totalSize || 0]));
|
|
130
|
+
const albums = (await db
|
|
131
|
+
.collection('albums')
|
|
132
|
+
.find({}, { projection: { userId: 1, groupId: 1, totalSize: 1, name: 1 } })
|
|
133
|
+
.toArray());
|
|
134
|
+
const albumIds = new Set(albums.map(a => String(a._id)));
|
|
135
|
+
for (const album of albums) {
|
|
136
|
+
const realSize = sizeByAlbum.get(String(album._id)) ?? 0;
|
|
137
|
+
if ((album.totalSize ?? 0) !== realSize) {
|
|
138
|
+
warnings.push(`album ${album._id} ("${album.name}") totalSize=${album.totalSize} but photos sum to ${realSize}; ` +
|
|
139
|
+
`metering the photo sum. The album counter is left alone — it is album-service's to maintain, ` +
|
|
140
|
+
`and silently rewriting another service's denormalized field would hide the bug rather than surface it.`);
|
|
141
|
+
}
|
|
142
|
+
const subject = subjectFor(album, 'album');
|
|
143
|
+
if (subject)
|
|
144
|
+
bucket(subject).storageBytes += realSize;
|
|
145
|
+
}
|
|
146
|
+
for (const row of photoTotals) {
|
|
147
|
+
if (!albumIds.has(String(row._id))) {
|
|
148
|
+
warnings.push(`${row.photoCount} photo(s) totalling ${row.totalSize} B reference missing album ${row._id}; UNATTRIBUTED`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// --- trackedTickers -------------------------------------------------------
|
|
152
|
+
// Distinct symbols, not rows: the daily Alpha Vantage spend this limit
|
|
153
|
+
// recovers is one call per distinct active symbol, however many rows hold it.
|
|
154
|
+
const tickers = (await db
|
|
155
|
+
.collection('trackedtickers')
|
|
156
|
+
.find({ isActive: true }, { projection: { userId: 1, groupId: 1, ticker: 1 } })
|
|
157
|
+
.toArray());
|
|
158
|
+
for (const ticker of tickers) {
|
|
159
|
+
const subject = subjectFor(ticker, 'trackedTicker');
|
|
160
|
+
if (subject && ticker.ticker)
|
|
161
|
+
bucket(subject).trackedTickers.add(String(ticker.ticker).toUpperCase());
|
|
162
|
+
}
|
|
163
|
+
// --- seats ----------------------------------------------------------------
|
|
164
|
+
// Distinct people across every live group the subject owns. Soft-deleted
|
|
165
|
+
// groups are skipped: unlike storage and tickers they cost nothing and grant
|
|
166
|
+
// nobody access, so charging for them would charge for nothing.
|
|
167
|
+
for (const { doc, owner, deleted } of groupsById.values()) {
|
|
168
|
+
if (deleted || !owner)
|
|
169
|
+
continue;
|
|
170
|
+
const seats = bucket(owner).seats;
|
|
171
|
+
for (const member of doc.members ?? []) {
|
|
172
|
+
const email = normalizeEmail(member.email);
|
|
173
|
+
if (email)
|
|
174
|
+
seats.add(email);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { expected, warnings, scannedUsers: users.length };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Recompute every derived meter and, with `apply`, write back the ones that
|
|
181
|
+
* disagree.
|
|
182
|
+
*
|
|
183
|
+
* Idempotent: it computes absolute values and writes only differences, so a
|
|
184
|
+
* second run in a row is a no-op.
|
|
185
|
+
*/
|
|
186
|
+
async function reconcileUsageMeters(options = {}) {
|
|
187
|
+
const { apply = false, subject: only } = options;
|
|
188
|
+
const onlySubject = normalizeEmail(only);
|
|
189
|
+
const { expected, warnings, scannedUsers } = await computeExpected();
|
|
190
|
+
const current = await UsageMeter_1.UsageMeter.find({ meter: { $in: exports.RECONCILED_METERS }, period: 'ALL' }).lean();
|
|
191
|
+
const currentByKey = new Map(current.map(doc => [`${normalizeEmail(doc.subjectId)}::${doc.meter}`, doc]));
|
|
192
|
+
const subjects = Array.from(expected.keys())
|
|
193
|
+
.filter(email => !onlySubject || email === onlySubject)
|
|
194
|
+
.sort();
|
|
195
|
+
const changes = [];
|
|
196
|
+
const ops = [];
|
|
197
|
+
for (const subjectId of subjects) {
|
|
198
|
+
const counts = expected.get(subjectId);
|
|
199
|
+
const resolved = {
|
|
200
|
+
storageBytes: counts.storageBytes,
|
|
201
|
+
trackedTickers: counts.trackedTickers.size,
|
|
202
|
+
seats: counts.seats.size
|
|
203
|
+
};
|
|
204
|
+
for (const meter of exports.RECONCILED_METERS) {
|
|
205
|
+
const to = resolved[meter];
|
|
206
|
+
const existing = currentByKey.get(`${subjectId}::${meter}`);
|
|
207
|
+
const from = existing ? existing.count : null;
|
|
208
|
+
if (from === to)
|
|
209
|
+
continue;
|
|
210
|
+
// Never create a row just to say zero — absence already reads as zero
|
|
211
|
+
// everywhere. An *existing* row must still be corrected down to zero, or
|
|
212
|
+
// a stale count outlives the data it was counting.
|
|
213
|
+
if (from === null && to === 0)
|
|
214
|
+
continue;
|
|
215
|
+
changes.push({ subjectId, meter, from, to });
|
|
216
|
+
ops.push({
|
|
217
|
+
updateOne: {
|
|
218
|
+
filter: { subjectId, meter, period: (0, UsageMeter_1.currentPeriod)(meter) },
|
|
219
|
+
update: {
|
|
220
|
+
$set: { count: to, updatedAt: new Date() },
|
|
221
|
+
$setOnInsert: { subjectId, meter, period: (0, UsageMeter_1.currentPeriod)(meter), createdAt: new Date() }
|
|
222
|
+
},
|
|
223
|
+
upsert: true
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Meters for subjects the scan no longer recognizes — a removed user, or a
|
|
229
|
+
// subjectId written before an ownership change. Reported, never deleted: a
|
|
230
|
+
// reconciliation that silently drops billing state is worse than the drift.
|
|
231
|
+
const orphanedMeters = current
|
|
232
|
+
.filter(doc => !expected.has(normalizeEmail(doc.subjectId) ?? ''))
|
|
233
|
+
.map(doc => ({ subjectId: doc.subjectId, meter: doc.meter, count: doc.count }));
|
|
234
|
+
let written = 0;
|
|
235
|
+
if (apply && ops.length > 0) {
|
|
236
|
+
const result = await UsageMeter_1.UsageMeter.bulkWrite(ops);
|
|
237
|
+
written = result.modifiedCount + result.upsertedCount;
|
|
238
|
+
}
|
|
239
|
+
const expectedRecord = {};
|
|
240
|
+
for (const subjectId of subjects) {
|
|
241
|
+
const counts = expected.get(subjectId);
|
|
242
|
+
expectedRecord[subjectId] = {
|
|
243
|
+
storageBytes: counts.storageBytes,
|
|
244
|
+
trackedTickers: counts.trackedTickers.size,
|
|
245
|
+
seats: counts.seats.size
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
applied: apply,
|
|
250
|
+
scannedUsers,
|
|
251
|
+
subjects: expected.size,
|
|
252
|
+
expected: expectedRecord,
|
|
253
|
+
changes,
|
|
254
|
+
written,
|
|
255
|
+
orphanedMeters,
|
|
256
|
+
warnings
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Run a reconciliation and log the outcome. The scheduled entry point.
|
|
261
|
+
*
|
|
262
|
+
* Never throws: this runs from a cron callback with no caller to catch it, and
|
|
263
|
+
* an unhandled rejection there takes the process down over a housekeeping job.
|
|
264
|
+
*/
|
|
265
|
+
async function runUsageReconciliation() {
|
|
266
|
+
const startedAt = Date.now();
|
|
267
|
+
try {
|
|
268
|
+
const report = await reconcileUsageMeters({ apply: true });
|
|
269
|
+
// A correction is drift that already happened — the counter had gone wrong
|
|
270
|
+
// and users were being metered against a wrong number until this ran.
|
|
271
|
+
// Logged at `warn` so a persistent leak in consumeQuota/releaseQuota is
|
|
272
|
+
// visible as a recurring signal rather than buried in an info line.
|
|
273
|
+
const level = report.changes.length > 0 ? 'warn' : 'info';
|
|
274
|
+
logger_1.default[level]('entitlement.reconciled', {
|
|
275
|
+
event: 'entitlement.reconciled',
|
|
276
|
+
subjects: report.subjects,
|
|
277
|
+
corrected: report.changes.length,
|
|
278
|
+
written: report.written,
|
|
279
|
+
orphaned: report.orphanedMeters.length,
|
|
280
|
+
warnings: report.warnings.length,
|
|
281
|
+
durationMs: Date.now() - startedAt,
|
|
282
|
+
changes: report.changes.slice(0, 20)
|
|
283
|
+
});
|
|
284
|
+
for (const warning of report.warnings.slice(0, 20)) {
|
|
285
|
+
logger_1.default.warn('entitlement.reconcile_warning', { event: 'entitlement.reconcile_warning', detail: warning });
|
|
286
|
+
}
|
|
287
|
+
return report;
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
logger_1.default.error('Usage meter reconciliation failed', {
|
|
291
|
+
error: err?.message,
|
|
292
|
+
durationMs: Date.now() - startedAt
|
|
293
|
+
});
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=reconcile.js.map
|