@tumbaland/backend-core 1.24.0 → 1.26.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.
Files changed (65) hide show
  1. package/dist/app/createBaseApp.d.ts +24 -0
  2. package/dist/app/createBaseApp.d.ts.map +1 -1
  3. package/dist/app/createBaseApp.js +20 -1
  4. package/dist/app/createBaseApp.js.map +1 -1
  5. package/dist/entitlements/client.d.ts.map +1 -1
  6. package/dist/entitlements/client.js +17 -1
  7. package/dist/entitlements/client.js.map +1 -1
  8. package/dist/entitlements/index.d.ts +3 -2
  9. package/dist/entitlements/index.d.ts.map +1 -1
  10. package/dist/entitlements/index.js +5 -4
  11. package/dist/entitlements/index.js.map +1 -1
  12. package/dist/entitlements/middleware.d.ts.map +1 -1
  13. package/dist/entitlements/middleware.js +8 -19
  14. package/dist/entitlements/middleware.js.map +1 -1
  15. package/dist/entitlements/reconcile.d.ts +74 -0
  16. package/dist/entitlements/reconcile.d.ts.map +1 -0
  17. package/dist/entitlements/reconcile.js +297 -0
  18. package/dist/entitlements/reconcile.js.map +1 -0
  19. package/dist/entitlements/types.d.ts +0 -8
  20. package/dist/entitlements/types.d.ts.map +1 -1
  21. package/dist/entitlements/usage.d.ts +1 -1
  22. package/dist/entitlements/usage.d.ts.map +1 -1
  23. package/dist/entitlements/usage.js +11 -20
  24. package/dist/entitlements/usage.js.map +1 -1
  25. package/dist/groups/client.d.ts +27 -0
  26. package/dist/groups/client.d.ts.map +1 -0
  27. package/dist/groups/client.js +153 -0
  28. package/dist/groups/client.js.map +1 -0
  29. package/dist/groups/index.d.ts +5 -0
  30. package/dist/groups/index.d.ts.map +1 -0
  31. package/dist/groups/index.js +10 -0
  32. package/dist/groups/index.js.map +1 -0
  33. package/dist/groups/subject.d.ts +30 -0
  34. package/dist/groups/subject.d.ts.map +1 -0
  35. package/dist/groups/subject.js +52 -0
  36. package/dist/groups/subject.js.map +1 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/app/createBaseApp.test.ts +31 -0
  43. package/src/app/createBaseApp.ts +28 -1
  44. package/src/entitlements/client.test.ts +30 -0
  45. package/src/entitlements/client.ts +18 -1
  46. package/src/entitlements/index.ts +4 -2
  47. package/src/entitlements/middleware.test.ts +9 -30
  48. package/src/entitlements/middleware.ts +8 -19
  49. package/src/entitlements/reconcile.test.ts +333 -0
  50. package/src/entitlements/reconcile.ts +384 -0
  51. package/src/entitlements/types.ts +0 -9
  52. package/src/entitlements/usage.test.ts +27 -31
  53. package/src/entitlements/usage.ts +12 -22
  54. package/src/groups/client.test.ts +215 -0
  55. package/src/groups/client.ts +182 -0
  56. package/src/groups/index.ts +5 -0
  57. package/src/groups/subject.test.ts +85 -0
  58. package/src/groups/subject.ts +50 -0
  59. package/src/index.ts +3 -0
  60. package/dist/entitlements/mode.d.ts +0 -10
  61. package/dist/entitlements/mode.d.ts.map +0 -1
  62. package/dist/entitlements/mode.js +0 -32
  63. package/dist/entitlements/mode.js.map +0 -1
  64. package/src/entitlements/mode.test.ts +0 -50
  65. package/src/entitlements/mode.ts +0 -28
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/groups/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,YAAY,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC"}
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groupOwnerSubject = exports.clearGroupOwnerCache = exports.invalidateGroupOwner = exports.getGroupOwner = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "getGroupOwner", { enumerable: true, get: function () { return client_1.getGroupOwner; } });
6
+ Object.defineProperty(exports, "invalidateGroupOwner", { enumerable: true, get: function () { return client_1.invalidateGroupOwner; } });
7
+ Object.defineProperty(exports, "clearGroupOwnerCache", { enumerable: true, get: function () { return client_1.clearGroupOwnerCache; } });
8
+ var subject_1 = require("./subject");
9
+ Object.defineProperty(exports, "groupOwnerSubject", { enumerable: true, get: function () { return subject_1.groupOwnerSubject; } });
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/groups/index.ts"],"names":[],"mappings":";;;AAAA,mCAAqF;AAA5E,uGAAA,aAAa,OAAA;AAAE,8GAAA,oBAAoB,OAAA;AAAE,8GAAA,oBAAoB,OAAA;AAGlE,qCAA8C;AAArC,4GAAA,iBAAiB,OAAA"}
@@ -0,0 +1,30 @@
1
+ import { Request } from 'express';
2
+ import { SubjectResolver } from '../entitlements/middleware';
3
+ export type GroupIdResolver = (req: Request) => string | undefined | Promise<string | undefined>;
4
+ /**
5
+ * Builds the `subject` resolver that `requireQuota` / `requireEntitlement`
6
+ * already accept, for the meters whose resource can live in a group.
7
+ *
8
+ * ```ts
9
+ * requireQuota('storageBytes', {
10
+ * subject: groupOwnerSubject(req => req.body.groupId),
11
+ * amount: req => req.body.sizeBytes
12
+ * })
13
+ * ```
14
+ *
15
+ * Personal rows keep billing to the caller; group rows bill to the group's
16
+ * owner, because Family sells *pooled* storage and seats and the uploader's own
17
+ * free tier is not the pool.
18
+ *
19
+ * **When the owner cannot be resolved, it bills the caller and says so.** The
20
+ * alternative — refusing the write — is harsher than what the entitlement
21
+ * client itself does for the same class of outage (it degrades to free limits
22
+ * rather than denying), and the error direction here is bounded in the shape
23
+ * that matters: the common case is a member on a cheap personal plan uploading
24
+ * into a group owned by someone on Family, so falling back to the caller checks
25
+ * against the *smaller* allowance. It can over-grant when the caller happens to
26
+ * hold the richer plan, which is why it logs at `error` — a run of these means
27
+ * group-service is down, not that a limit is wrong.
28
+ */
29
+ export declare function groupOwnerSubject(resolveGroupId: GroupIdResolver): SubjectResolver;
30
+ //# sourceMappingURL=subject.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../../src/groups/subject.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAG7D,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjG;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,eAAe,GAAG,eAAe,CAiBlF"}
@@ -0,0 +1,52 @@
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.groupOwnerSubject = groupOwnerSubject;
7
+ const logger_1 = __importDefault(require("../logging/logger"));
8
+ const client_1 = require("./client");
9
+ /**
10
+ * Builds the `subject` resolver that `requireQuota` / `requireEntitlement`
11
+ * already accept, for the meters whose resource can live in a group.
12
+ *
13
+ * ```ts
14
+ * requireQuota('storageBytes', {
15
+ * subject: groupOwnerSubject(req => req.body.groupId),
16
+ * amount: req => req.body.sizeBytes
17
+ * })
18
+ * ```
19
+ *
20
+ * Personal rows keep billing to the caller; group rows bill to the group's
21
+ * owner, because Family sells *pooled* storage and seats and the uploader's own
22
+ * free tier is not the pool.
23
+ *
24
+ * **When the owner cannot be resolved, it bills the caller and says so.** The
25
+ * alternative — refusing the write — is harsher than what the entitlement
26
+ * client itself does for the same class of outage (it degrades to free limits
27
+ * rather than denying), and the error direction here is bounded in the shape
28
+ * that matters: the common case is a member on a cheap personal plan uploading
29
+ * into a group owned by someone on Family, so falling back to the caller checks
30
+ * against the *smaller* allowance. It can over-grant when the caller happens to
31
+ * hold the richer plan, which is why it logs at `error` — a run of these means
32
+ * group-service is down, not that a limit is wrong.
33
+ */
34
+ function groupOwnerSubject(resolveGroupId) {
35
+ return async (req) => {
36
+ const caller = req.user?.email;
37
+ const groupId = await resolveGroupId(req);
38
+ if (!groupId)
39
+ return caller;
40
+ const owner = await (0, client_1.getGroupOwner)(String(groupId));
41
+ if (owner)
42
+ return owner.ownerEmail;
43
+ logger_1.default.error('Group owner unresolved; billing usage to the caller instead', {
44
+ event: 'entitlement.subject_fallback',
45
+ groupId: String(groupId),
46
+ caller,
47
+ route: req.originalUrl
48
+ });
49
+ return caller;
50
+ };
51
+ }
52
+ //# sourceMappingURL=subject.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject.js","sourceRoot":"","sources":["../../src/groups/subject.ts"],"names":[],"mappings":";;;;;AAgCA,8CAiBC;AAhDD,+DAAuC;AAEvC,qCAAyC;AAIzC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,SAAgB,iBAAiB,CAAC,cAA+B;IAC/D,OAAO,KAAK,EAAE,GAAY,EAAE,EAAE;QAC5B,MAAM,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC;QAC/B,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,GAAG,CAAC,CAAC;QAC1C,IAAI,CAAC,OAAO;YAAE,OAAO,MAAM,CAAC;QAE5B,MAAM,KAAK,GAAG,MAAM,IAAA,sBAAa,EAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;QACnD,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC,UAAU,CAAC;QAEnC,gBAAM,CAAC,KAAK,CAAC,6DAA6D,EAAE;YAC1E,KAAK,EAAE,8BAA8B;YACrC,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC;YACxB,MAAM;YACN,KAAK,EAAE,GAAG,CAAC,WAAW;SACvB,CAAC,CAAC;QACH,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { requireEnv } from './config/env';
11
11
  export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, PaymentRequiredError } from './errors/HttpError';
12
12
  export type { PaymentRequiredDetails } from './errors/HttpError';
13
13
  export * from './entitlements';
14
+ export * from './groups';
14
15
  export { authenticateToken } from './middleware/authMiddleware';
15
16
  export { requireInternalServiceToken, internalServiceTokenBypass, allowUserOrInternalService, verifyInternalToken, INTERNAL_TOKEN_HEADER, SERVICE_ID_HEADER } from './middleware/internalServiceAuth';
16
17
  export { createCorsMiddleware } from './middleware/corsMiddleware';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAG9D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC7K,YAAY,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAGjE,cAAc,gBAAgB,CAAC;AAG/B,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAC1G,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACnH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EACf,QAAQ,EACT,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,UAAU,EACV,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,UAAU,EACV,aAAa,EACb,kBAAkB,EACnB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAG9D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC7K,YAAY,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAGjE,cAAc,gBAAgB,CAAC;AAG/B,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAC1G,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACnH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EACf,QAAQ,EACT,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,UAAU,EACV,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,UAAU,EACV,aAAa,EACb,kBAAkB,EACnB,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -54,6 +54,8 @@ Object.defineProperty(exports, "TooManyRequestsError", { enumerable: true, get:
54
54
  Object.defineProperty(exports, "PaymentRequiredError", { enumerable: true, get: function () { return HttpError_1.PaymentRequiredError; } });
55
55
  // Entitlements (plan limits, usage meters, enforcement)
56
56
  __exportStar(require("./entitlements"), exports);
57
+ // Group ownership (the quota subject for anything pooled)
58
+ __exportStar(require("./groups"), exports);
57
59
  // Middleware
58
60
  var authMiddleware_1 = require("./middleware/authMiddleware");
59
61
  Object.defineProperty(exports, "authenticateToken", { enumerable: true, get: function () { return authMiddleware_1.authenticateToken; } });
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,qFAAqF;;;;;;;;;;;;;;;;;;;;;AAErF,gBAAgB;AAChB,qDAAoD;AAA3C,8GAAA,aAAa,OAAA;AAEtB,2CAAkD;AAAzC,4GAAA,gBAAgB,OAAA;AAGzB,UAAU;AACV,2CAAqD;AAA5C,iHAAA,OAAO,OAAU;AAE1B,gBAAgB;AAChB,8DAAoF;AAA3E,+GAAA,WAAW,OAAA;AAAE,8GAAA,UAAU,OAAA;AAAE,kHAAA,cAAc,OAAA;AAChD,gEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAG1B,WAAW;AACX,oDAAgE;AAAvD,uGAAA,SAAS,OAAA;AAAE,0GAAA,YAAY,OAAA;AAEhC,SAAS;AACT,oCAA0C;AAAjC,iGAAA,UAAU,OAAA;AAEnB,SAAS;AACT,gDAA6K;AAApK,sGAAA,SAAS,OAAA;AAAE,4GAAA,eAAe,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAGhJ,wDAAwD;AACxD,iDAA+B;AAE/B,aAAa;AACb,8DAAgE;AAAvD,mHAAA,iBAAiB,OAAA;AAC1B,wEAO0C;AANxC,kIAAA,2BAA2B,OAAA;AAC3B,iIAAA,0BAA0B,OAAA;AAC1B,iIAAA,0BAA0B,OAAA;AAC1B,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,wHAAA,iBAAiB,OAAA;AAEnB,8DAAmE;AAA1D,sHAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,4GAAA,YAAY,OAAA;AACrB,4DAA0G;AAAjG,8GAAA,aAAa,OAAA;AAAE,yHAAA,wBAAwB,OAAA;AAAE,oHAAA,mBAAmB,OAAA;AACrE,kDAAmH;AAA1G,2GAAA,eAAe,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AAAE,+GAAA,mBAAmB,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AACnF,kDAAiD;AAAxC,oGAAA,QAAQ,OAAA;AAKjB,QAAQ;AACR,mDAAmF;AAA1E,oHAAA,qBAAqB,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AACrD,6CAA6E;AAApE,6GAAA,iBAAiB,OAAA;AAAE,uGAAA,WAAW,OAAA;AAAE,qGAAA,SAAS,OAAA;AAElD,2DAA8E;AAArE,mHAAA,gBAAgB,OAAA;AAAE,oHAAA,iBAAiB,OAAA;AAE5C,UAAU;AACV,qCAQmB;AAPjB,4GAAA,iBAAiB,OAAA;AACjB,8GAAA,mBAAmB,OAAA;AACnB,4GAAA,iBAAiB,OAAA;AACjB,gHAAA,qBAAqB,OAAA;AACrB,+GAAA,oBAAoB,OAAA;AACpB,0GAAA,eAAe,OAAA;AACf,mGAAA,QAAQ,OAAA;AAGV,UAAU;AACV,qCAUmB;AATjB,qGAAA,UAAU,OAAA;AACV,oGAAA,SAAS,OAAA;AACT,oGAAA,SAAS,OAAA;AACT,4GAAA,iBAAiB,OAAA;AACjB,0GAAA,eAAe,OAAA;AACf,oGAAA,SAAS,OAAA;AACT,qGAAA,UAAU,OAAA;AACV,wGAAA,aAAa,OAAA;AACb,6GAAA,kBAAkB,OAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,qFAAqF;;;;;;;;;;;;;;;;;;;;;AAErF,gBAAgB;AAChB,qDAAoD;AAA3C,8GAAA,aAAa,OAAA;AAEtB,2CAAkD;AAAzC,4GAAA,gBAAgB,OAAA;AAGzB,UAAU;AACV,2CAAqD;AAA5C,iHAAA,OAAO,OAAU;AAE1B,gBAAgB;AAChB,8DAAoF;AAA3E,+GAAA,WAAW,OAAA;AAAE,8GAAA,UAAU,OAAA;AAAE,kHAAA,cAAc,OAAA;AAChD,gEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAG1B,WAAW;AACX,oDAAgE;AAAvD,uGAAA,SAAS,OAAA;AAAE,0GAAA,YAAY,OAAA;AAEhC,SAAS;AACT,oCAA0C;AAAjC,iGAAA,UAAU,OAAA;AAEnB,SAAS;AACT,gDAA6K;AAApK,sGAAA,SAAS,OAAA;AAAE,4GAAA,eAAe,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAGhJ,wDAAwD;AACxD,iDAA+B;AAE/B,0DAA0D;AAC1D,2CAAyB;AAEzB,aAAa;AACb,8DAAgE;AAAvD,mHAAA,iBAAiB,OAAA;AAC1B,wEAO0C;AANxC,kIAAA,2BAA2B,OAAA;AAC3B,iIAAA,0BAA0B,OAAA;AAC1B,iIAAA,0BAA0B,OAAA;AAC1B,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,wHAAA,iBAAiB,OAAA;AAEnB,8DAAmE;AAA1D,sHAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,4GAAA,YAAY,OAAA;AACrB,4DAA0G;AAAjG,8GAAA,aAAa,OAAA;AAAE,yHAAA,wBAAwB,OAAA;AAAE,oHAAA,mBAAmB,OAAA;AACrE,kDAAmH;AAA1G,2GAAA,eAAe,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AAAE,+GAAA,mBAAmB,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AACnF,kDAAiD;AAAxC,oGAAA,QAAQ,OAAA;AAKjB,QAAQ;AACR,mDAAmF;AAA1E,oHAAA,qBAAqB,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AACrD,6CAA6E;AAApE,6GAAA,iBAAiB,OAAA;AAAE,uGAAA,WAAW,OAAA;AAAE,qGAAA,SAAS,OAAA;AAElD,2DAA8E;AAArE,mHAAA,gBAAgB,OAAA;AAAE,oHAAA,iBAAiB,OAAA;AAE5C,UAAU;AACV,qCAQmB;AAPjB,4GAAA,iBAAiB,OAAA;AACjB,8GAAA,mBAAmB,OAAA;AACnB,4GAAA,iBAAiB,OAAA;AACjB,gHAAA,qBAAqB,OAAA;AACrB,+GAAA,oBAAoB,OAAA;AACpB,0GAAA,eAAe,OAAA;AACf,mGAAA,QAAQ,OAAA;AAGV,UAAU;AACV,qCAUmB;AATjB,qGAAA,UAAU,OAAA;AACV,oGAAA,SAAS,OAAA;AACT,oGAAA,SAAS,OAAA;AACT,4GAAA,iBAAiB,OAAA;AACjB,0GAAA,eAAe,OAAA;AACf,oGAAA,SAAS,OAAA;AACT,qGAAA,UAAU,OAAA;AACV,wGAAA,aAAa,OAAA;AACb,6GAAA,kBAAkB,OAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -63,6 +63,37 @@ describe('createBaseApp', () => {
63
63
  expect(res.status).toBe(200);
64
64
  });
65
65
 
66
+ it('trusts the proxy, so X-Forwarded-For identifies the client rather than being ignored', async () => {
67
+ const app = createBaseApp();
68
+ app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
69
+
70
+ const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
71
+
72
+ expect(res.body.ip).toBe('203.0.113.7');
73
+ });
74
+
75
+ it('keeps rate-limit buckets per client, not one shared bucket behind the proxy', async () => {
76
+ const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
77
+ app.get('/ping', (_req, res) => res.json({ ok: true }));
78
+
79
+ const clientA = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
80
+ const clientB = await request(app).get('/ping').set('X-Forwarded-For', '198.51.100.4');
81
+ const clientAAgain = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
82
+
83
+ expect(clientA.status).toBe(200);
84
+ expect(clientB.status).toBe(200); // a different client is unaffected by A exhausting its window
85
+ expect(clientAAgain.status).toBe(429);
86
+ });
87
+
88
+ it('ignores X-Forwarded-For when trustProxy is disabled', async () => {
89
+ const app = createBaseApp({ trustProxy: false });
90
+ app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
91
+
92
+ const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
93
+
94
+ expect(res.body.ip).not.toBe('203.0.113.7');
95
+ });
96
+
66
97
  it('mounts /health/live ahead of the rate limiter, so it never gets 429s a busy service would', async () => {
67
98
  const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
68
99
  app.get('/ping', (_req, res) => res.json({ ok: true }));
@@ -25,6 +25,12 @@ export interface CreateBaseAppOptions {
25
25
  tracing?: boolean;
26
26
  /** Use requestLoggerWithMetrics (adds Prometheus histograms) instead of the plain requestLogger. Default true. */
27
27
  metrics?: boolean;
28
+ /**
29
+ * Value for Express's `trust proxy`. Defaults to trusting private/loopback
30
+ * addresses — see the note on tumbaland-proxy below. Pass `false` for a
31
+ * service exposed directly to clients with no proxy in front of it.
32
+ */
33
+ trustProxy?: Parameters<Express['set']>[1];
28
34
  }
29
35
 
30
36
  /**
@@ -47,6 +53,24 @@ export interface CreateBaseAppOptions {
47
53
  * Services with non-standard body parsing (a webhook needing the raw body,
48
54
  * or conditional parsing per-route) should pass `parseJson`/`parseCookies:
49
55
  * false` and mount those themselves at the exact point they need to.
56
+ *
57
+ * `trust proxy` is on by default because nothing here is reached directly:
58
+ * every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
59
+ * Left at Express's default (`false`) that header is ignored, which
60
+ * express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
61
+ * it is right to: the limiter would then key every request in the cluster on
62
+ * the proxy's own container IP, so one busy client could exhaust the window
63
+ * for everyone.
64
+ *
65
+ * The value is `loopback, linklocal, uniquelocal` (private ranges) rather
66
+ * than a hop count, because the chain length differs per ingress path: a
67
+ * direct hit arrives as `client` + the proxy's own socket address, while
68
+ * traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
69
+ * local cloudflared) + that same socket address. Express walks the chain
70
+ * right-to-left and stops at the first address it does not trust, so both
71
+ * paths land on the real client; a fixed number would be wrong for one of
72
+ * them. All the intermediaries are private addresses and the client is not,
73
+ * which is exactly the split this preset encodes.
50
74
  */
51
75
  export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
52
76
  const {
@@ -55,11 +79,14 @@ export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
55
79
  parseJson = true,
56
80
  parseCookies = true,
57
81
  tracing = true,
58
- metrics = true
82
+ metrics = true,
83
+ trustProxy = 'loopback, linklocal, uniquelocal'
59
84
  } = options;
60
85
 
61
86
  const app = express();
62
87
 
88
+ app.set('trust proxy', trustProxy);
89
+
63
90
  app.get('/health/live', healthLive);
64
91
 
65
92
  app.use(securityHeaders);
@@ -150,6 +150,36 @@ describe('when payment-service cannot be reached', () => {
150
150
  });
151
151
  });
152
152
 
153
+ describe('base URL selection', () => {
154
+ it('prefers the container-network address over the public hostname', async () => {
155
+ process.env.PAYMENT_INTERNAL_URL = 'http://payment-service:5005';
156
+ process.env.PAYMENT_API_URL = 'https://payment-api.tumbaland.eu';
157
+ (global.fetch as jest.Mock).mockResolvedValue(paidResponse());
158
+
159
+ await getEntitlements('user@example.com');
160
+
161
+ // payment-front templates the public variable into the browser's
162
+ // config.json, so it cannot be pointed inward — hence the second variable.
163
+ expect(global.fetch).toHaveBeenCalledWith(
164
+ 'http://payment-service:5005/internal/entitlements?email=user%40example.com',
165
+ expect.anything()
166
+ );
167
+ });
168
+
169
+ it('falls back to the public URL, so local dev and un-migrated deploys still work', async () => {
170
+ delete process.env.PAYMENT_INTERNAL_URL;
171
+ process.env.PAYMENT_API_URL = 'https://payment-api.tumbaland.eu';
172
+ (global.fetch as jest.Mock).mockResolvedValue(paidResponse());
173
+
174
+ await getEntitlements('user@example.com');
175
+
176
+ expect(global.fetch).toHaveBeenCalledWith(
177
+ 'https://payment-api.tumbaland.eu/internal/entitlements?email=user%40example.com',
178
+ expect.anything()
179
+ );
180
+ });
181
+ });
182
+
153
183
  describe('cache bounds', () => {
154
184
  it('evicts the oldest entries instead of growing without limit', async () => {
155
185
  (global.fetch as jest.Mock).mockResolvedValue(paidResponse());
@@ -72,8 +72,25 @@ interface EntitlementResponse {
72
72
  upgradeTo?: string;
73
73
  }
74
74
 
75
+ /**
76
+ * Prefers the container-network address over the public hostname.
77
+ *
78
+ * `PAYMENT_API_URL` cannot simply be pointed inward: payment-front's entrypoint
79
+ * templates that same variable into the `config.json` the *browser* downloads,
80
+ * so a Docker-internal value there breaks the subscription and checkout pages.
81
+ * One variable, two consumers with incompatible needs — this is the server-side
82
+ * half of it.
83
+ *
84
+ * Falling back keeps local dev (where only `PAYMENT_API_URL` is set) and any
85
+ * un-migrated deployment working; it just takes the long way round, out through
86
+ * the proxy and back, carrying the internal token across the public edge.
87
+ */
88
+ function internalPaymentUrl(): string | undefined {
89
+ return process.env.PAYMENT_INTERNAL_URL || process.env.PAYMENT_API_URL;
90
+ }
91
+
75
92
  async function fetchEntitlements(email: string): Promise<Entitlements> {
76
- const baseUrl = process.env.PAYMENT_API_URL;
93
+ const baseUrl = internalPaymentUrl();
77
94
  const token = process.env.INTERNAL_SERVICE_TOKEN;
78
95
  const serviceId = process.env.SERVICE_NAME || 'unknown-service';
79
96
 
@@ -26,14 +26,16 @@ export type {
26
26
  PartialPlanLimits
27
27
  } from './definitions';
28
28
 
29
- export type { Entitlements, EntitlementsMode } from './types';
30
- export { getEntitlementsMode, resetEntitlementsModeWarning } from './mode';
29
+ export type { Entitlements } from './types';
31
30
 
32
31
  export { UsageMeter, currentPeriod } from './UsageMeter';
33
32
  export type { IUsageMeter } from './UsageMeter';
34
33
  export { getUsage, getUsageSnapshot, checkQuota, consumeQuota, releaseQuota, setUsage } from './usage';
35
34
  export type { QuotaRequest } from './usage';
36
35
 
36
+ export { reconcileUsageMeters, runUsageReconciliation, RECONCILED_METERS } from './reconcile';
37
+ export type { ReconcileOptions, ReconcileReport, MeterChange, OrphanedMeter } from './reconcile';
38
+
37
39
  export { getEntitlements, invalidateEntitlements, clearEntitlementsCache } from './client';
38
40
 
39
41
  export { loadEntitlements, requireEntitlement, requireQuota, retentionFloor } from './middleware';
@@ -35,7 +35,7 @@ function mockReq(overrides: Partial<Request> = {}): Request {
35
35
  }
36
36
 
37
37
  beforeEach(() => {
38
- process.env = { ...ORIGINAL_ENV, ENTITLEMENTS_MODE: 'enforce' };
38
+ process.env = { ...ORIGINAL_ENV };
39
39
  jest.clearAllMocks();
40
40
  mockGetEntitlements.mockResolvedValue(entitlements());
41
41
  });
@@ -89,24 +89,10 @@ describe('requireEntitlement', () => {
89
89
  );
90
90
  });
91
91
 
92
- it('logs instead of blocking in observe mode', async () => {
93
- process.env.ENTITLEMENTS_MODE = 'observe';
94
- const next = jest.fn();
95
-
96
- await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
97
-
98
- expect(next).toHaveBeenCalledWith();
99
- expect(logger.info).toHaveBeenCalledWith('entitlement.would_block', expect.objectContaining({ feature: 'cleanExport' }));
100
- });
101
-
102
- it('does not even look entitlements up when the mode is off', async () => {
103
- process.env.ENTITLEMENTS_MODE = 'off';
104
- const next = jest.fn();
105
-
106
- await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
92
+ it('logs the denial alongside throwing it', async () => {
93
+ await requireEntitlement('cleanExport')(mockReq(), {} as Response, jest.fn());
107
94
 
108
- expect(next).toHaveBeenCalledWith();
109
- expect(mockGetEntitlements).not.toHaveBeenCalled();
95
+ expect(logger.info).toHaveBeenCalledWith('entitlement.blocked', expect.objectContaining({ feature: 'cleanExport' }));
110
96
  });
111
97
  });
112
98
 
@@ -143,15 +129,6 @@ describe('requireQuota', () => {
143
129
  expect(next).toHaveBeenCalledWith(denial);
144
130
  });
145
131
 
146
- it('is inert when the mode is off', async () => {
147
- process.env.ENTITLEMENTS_MODE = 'off';
148
- const next = jest.fn();
149
-
150
- await requireQuota('storageBytes')(mockReq(), {} as Response, next);
151
-
152
- expect(mockCheckQuota).not.toHaveBeenCalled();
153
- expect(next).toHaveBeenCalledWith();
154
- });
155
132
  });
156
133
 
157
134
  describe('retentionFloor', () => {
@@ -171,9 +148,11 @@ describe('retentionFloor', () => {
171
148
  expect(await retentionFloor('user@example.com', now)).toBeNull();
172
149
  });
173
150
 
174
- it('never truncates history outside enforce mode', async () => {
175
- process.env.ENTITLEMENTS_MODE = 'observe';
151
+ it('still applies the floor when the limits are a stale fallback', async () => {
152
+ mockGetEntitlements.mockResolvedValue(entitlements({ stale: true }));
176
153
 
177
- expect(await retentionFloor('user@example.com', now)).toBeNull();
154
+ // Fail closed, same as every other gate: an outage clips history to the
155
+ // free tier for a minute rather than handing the full archive out.
156
+ expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
178
157
  });
179
158
  });
@@ -4,7 +4,6 @@ import { PaymentRequiredError, UnauthorizedError } from '../errors/HttpError';
4
4
  import { FeatureKey, MeterKey, UNLIMITED } from './definitions';
5
5
  import { getEntitlements } from './client';
6
6
  import { checkQuota } from './usage';
7
- import { getEntitlementsMode } from './mode';
8
7
  import { Entitlements } from './types';
9
8
 
10
9
  declare global {
@@ -51,23 +50,17 @@ export async function loadEntitlements(req: Request, resolve: SubjectResolver =
51
50
  export function requireEntitlement(feature: FeatureKey, options: EntitlementOptions = {}): RequestHandler {
52
51
  return async (req, _res, next) => {
53
52
  try {
54
- const mode = getEntitlementsMode();
55
- if (mode === 'off') return next();
56
-
57
53
  const entitlements = await loadEntitlements(req, options.subject);
58
54
  if (entitlements.limits.features[feature]) return next();
59
55
 
60
- if (mode === 'observe') {
61
- logger.info('entitlement.would_block', {
62
- event: 'entitlement.would_block',
63
- feature,
64
- subjectId: entitlements.email,
65
- planCode: entitlements.planCode,
66
- route: req.originalUrl,
67
- stale: entitlements.stale === true
68
- });
69
- return next();
70
- }
56
+ logger.info('entitlement.blocked', {
57
+ event: 'entitlement.blocked',
58
+ feature,
59
+ subjectId: entitlements.email,
60
+ planCode: entitlements.planCode,
61
+ route: req.originalUrl,
62
+ stale: entitlements.stale === true
63
+ });
71
64
 
72
65
  throw new PaymentRequiredError(`Your plan does not include ${feature}`, {
73
66
  code: 'FEATURE_NOT_IN_PLAN',
@@ -98,8 +91,6 @@ export interface QuotaOptions extends EntitlementOptions {
98
91
  export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): RequestHandler {
99
92
  return async (req, _res, next) => {
100
93
  try {
101
- if (getEntitlementsMode() === 'off') return next();
102
-
103
94
  const entitlements = await loadEntitlements(req, options.subject);
104
95
  const amount = typeof options.amount === 'function' ? options.amount(req) : (options.amount ?? 1);
105
96
 
@@ -126,8 +117,6 @@ export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): Reque
126
117
  * price history it depends on is not.
127
118
  */
128
119
  export async function retentionFloor(email: string, now: Date = new Date()): Promise<Date | null> {
129
- if (getEntitlementsMode() !== 'enforce') return null;
130
-
131
120
  const { limits } = await getEntitlements(email);
132
121
  const days = limits.retentionDays;
133
122
  if (days === UNLIMITED || days < 0) return null;