@tumbaland/backend-core 1.26.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.
@@ -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 user may read, or `null` for unlimited history.
46
+ * The oldest timestamp a caller may read, or `null` for unlimited history.
47
47
  *
48
- * Meant to be dropped straight into an aggregation `$match` — this is what
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(email: string, now?: Date): Promise<Date | null>;
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,EAAE,UAAU,EAAE,QAAQ,EAAa,MAAM,eAAe,CAAC;AAGhE,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;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAMhG"}
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"}
@@ -81,18 +81,35 @@ function requireQuota(meter, options = {}) {
81
81
  };
82
82
  }
83
83
  /**
84
- * The oldest timestamp a user may read, or `null` for unlimited history.
84
+ * The oldest timestamp a caller may read, or `null` for unlimited history.
85
85
  *
86
- * Meant to be dropped straight into an aggregation `$match` — this is what
86
+ * Meant to be dropped straight into a `$match` or a `find` filter — this is what
87
87
  * gates the portfolio planner, since the planner runs entirely client-side and
88
88
  * any `<PaywallGate>` around it is bypassable from devtools. Truncating the
89
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.
90
101
  */
91
- async function retentionFloor(email, now = new Date()) {
92
- const { limits } = await (0, client_1.getEntitlements)(email);
93
- const days = limits.retentionDays;
94
- if (days === definitions_1.UNLIMITED || days < 0)
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))
95
112
  return null;
96
- return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
113
+ return floors.reduce((a, b) => (a < b ? a : b));
97
114
  }
98
115
  //# sourceMappingURL=middleware.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -156,3 +156,41 @@ describe('retentionFloor', () => {
156
156
  expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
157
157
  });
158
158
  });
159
+
160
+ describe('retentionFloor across several subjects', () => {
161
+ const now = new Date('2026-08-09T00:00:00.000Z');
162
+
163
+ it('takes the most generous floor, so a paid group lifts it for every member', () => {
164
+ mockGetEntitlements.mockImplementation(async (email: string) =>
165
+ email === 'owner@example.com'
166
+ ? entitlements({ planCode: 'family', limits: normalizeLimits({ retentionDays: UNLIMITED }, 'family') })
167
+ : entitlements()
168
+ );
169
+
170
+ // A free member reading a Family group's data must not be clipped to 90
171
+ // days of a household's history the household paid to keep.
172
+ return expect(retentionFloor(['member@example.com', 'owner@example.com'], now)).resolves.toBeNull();
173
+ });
174
+
175
+ it('picks the floor that reaches furthest back when none is unlimited', async () => {
176
+ mockGetEntitlements.mockImplementation(async (email: string) =>
177
+ entitlements({ limits: normalizeLimits({ retentionDays: email === 'owner@example.com' ? 365 : 90 }, 'free') })
178
+ );
179
+
180
+ expect(await retentionFloor(['member@example.com', 'owner@example.com'], now)).toEqual(
181
+ new Date('2025-08-09T00:00:00.000Z')
182
+ );
183
+ });
184
+
185
+ it('still accepts a single email', async () => {
186
+ mockGetEntitlements.mockResolvedValue(entitlements());
187
+
188
+ expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
189
+ });
190
+
191
+ it('falls back to the free floor when no subject can be identified', async () => {
192
+ // An unidentifiable caller must never resolve to unlimited history.
193
+ expect(await retentionFloor([], now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
194
+ expect(mockGetEntitlements).not.toHaveBeenCalled();
195
+ });
196
+ });
@@ -1,7 +1,7 @@
1
1
  import { Request, RequestHandler } from 'express';
2
2
  import logger from '../logging/logger';
3
3
  import { PaymentRequiredError, UnauthorizedError } from '../errors/HttpError';
4
- import { FeatureKey, MeterKey, UNLIMITED } from './definitions';
4
+ import { FALLBACK_PLAN, FeatureKey, MeterKey, UNLIMITED, resolveLimits } from './definitions';
5
5
  import { getEntitlements } from './client';
6
6
  import { checkQuota } from './usage';
7
7
  import { Entitlements } from './types';
@@ -109,17 +109,42 @@ export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): Reque
109
109
  }
110
110
 
111
111
  /**
112
- * The oldest timestamp a user may read, or `null` for unlimited history.
112
+ * The oldest timestamp a caller may read, or `null` for unlimited history.
113
113
  *
114
- * Meant to be dropped straight into an aggregation `$match` — this is what
114
+ * Meant to be dropped straight into a `$match` or a `find` filter — this is what
115
115
  * gates the portfolio planner, since the planner runs entirely client-side and
116
116
  * any `<PaywallGate>` around it is bypassable from devtools. Truncating the
117
117
  * price history it depends on is not.
118
+ *
119
+ * **Several subjects resolve to the most generous floor.** Retention is not a
120
+ * counter, so unlike a quota it has no single payer: data held in a group is
121
+ * read by every member, and if it resolved to the *caller's* plan alone, a free
122
+ * member of a paid Family group would see ninety days of a household's history
123
+ * that the household paid to keep. Pass the caller together with the owners of
124
+ * the groups being read, and the group's plan lifts the floor for everyone in
125
+ * it — which is what "shared across a household" was sold as.
126
+ *
127
+ * An empty list resolves to the free plan's floor rather than to `null`: no
128
+ * identifiable subject must never mean unlimited history.
118
129
  */
119
- export async function retentionFloor(email: string, now: Date = new Date()): Promise<Date | null> {
120
- const { limits } = await getEntitlements(email);
121
- const days = limits.retentionDays;
122
- if (days === UNLIMITED || days < 0) return null;
130
+ export async function retentionFloor(
131
+ subject: string | readonly string[],
132
+ now: Date = new Date()
133
+ ): Promise<Date | null> {
134
+ const emails = (Array.isArray(subject) ? subject : [subject as string]).filter(Boolean);
135
+ const floorFor = (days: number): Date | null =>
136
+ days === UNLIMITED || days < 0 ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
137
+
138
+ if (emails.length === 0) {
139
+ return floorFor(resolveLimits(FALLBACK_PLAN).retentionDays);
140
+ }
141
+
142
+ const floors = await Promise.all(
143
+ emails.map(async email => floorFor((await getEntitlements(email)).limits.retentionDays))
144
+ );
123
145
 
124
- return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
146
+ // `null` is unlimited, which beats every date; otherwise the earliest floor
147
+ // is the one that reaches furthest back.
148
+ if (floors.some(floor => floor === null)) return null;
149
+ return (floors as Date[]).reduce((a, b) => (a < b ? a : b));
125
150
  }