@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.
- package/dist/entitlements/middleware.d.ts +14 -3
- package/dist/entitlements/middleware.d.ts.map +1 -1
- package/dist/entitlements/middleware.js +24 -7
- package/dist/entitlements/middleware.js.map +1 -1
- package/package.json +1 -1
- package/src/entitlements/middleware.test.ts +38 -0
- package/src/entitlements/middleware.ts +33 -8
|
@@ -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"}
|
|
@@ -81,18 +81,35 @@ function requireQuota(meter, options = {}) {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
* The oldest timestamp a
|
|
84
|
+
* The oldest timestamp a caller may read, or `null` for unlimited history.
|
|
85
85
|
*
|
|
86
|
-
* Meant to be dropped straight into
|
|
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(
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
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
|
|
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":";;;;;AAkCA,4CASC;AAMD,gDAyBC;AAgBD,oCAkBC;
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/entitlements/middleware.ts"],"names":[],"mappings":";;;;;AAkCA,4CASC;AAMD,gDAyBC;AAgBD,oCAkBC;AAqBD,wCAoBC;AApJD,+DAAuC;AACvC,mDAA8E;AAC9E,+CAA8F;AAC9F,qCAA2C;AAC3C,mCAAqC;AAuBrC,MAAM,cAAc,GAAoB,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC;AAE/D;;;GAGG;AACI,KAAK,UAAU,gBAAgB,CAAC,GAAY,EAAE,UAA2B,cAAc;IAC5F,IAAI,GAAG,CAAC,YAAY;QAAE,OAAO,GAAG,CAAC,YAAY,CAAC;IAE9C,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;IACnC,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,6BAAiB,CAAC,yBAAyB,CAAC,CAAC;IAErE,MAAM,YAAY,GAAG,MAAM,IAAA,wBAAe,EAAC,OAAO,CAAC,CAAC;IACpD,GAAG,CAAC,YAAY,GAAG,YAAY,CAAC;IAChC,OAAO,YAAY,CAAC;AACtB,CAAC;AAED;;;GAGG;AACH,SAAgB,kBAAkB,CAAC,OAAmB,EAAE,UAA8B,EAAE;IACtF,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;QAC/B,IAAI,CAAC;YACH,MAAM,YAAY,GAAG,MAAM,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;YAClE,IAAI,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC;gBAAE,OAAO,IAAI,EAAE,CAAC;YAEzD,gBAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE;gBACjC,KAAK,EAAE,qBAAqB;gBAC5B,OAAO;gBACP,SAAS,EAAE,YAAY,CAAC,KAAK;gBAC7B,QAAQ,EAAE,YAAY,CAAC,QAAQ;gBAC/B,KAAK,EAAE,GAAG,CAAC,WAAW;gBACtB,KAAK,EAAE,YAAY,CAAC,KAAK,KAAK,IAAI;aACnC,CAAC,CAAC;YAEH,MAAM,IAAI,gCAAoB,CAAC,8BAA8B,OAAO,EAAE,EAAE;gBACtE,IAAI,EAAE,qBAAqB;gBAC3B,OAAO;gBACP,QAAQ,EAAE,YAAY,CAAC,QAAQ;gBAC/B,SAAS,EAAE,YAAY,CAAC,SAAS;aAClC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,GAAG,CAAC,CAAC;QACZ,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAYD;;;GAGG;AACH,SAAgB,YAAY,CAAC,KAAe,EAAE,UAAwB,EAAE;IACtE,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;QAC/B,IAAI,CAAC;YACH,MAAM,YAAY,GAAG,MAAM,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;YAClE,MAAM,MAAM,GAAG,OAAO,OAAO,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC;YAElG,MAAM,IAAA,kBAAU,EAAC;gBACf,SAAS,EAAE,YAAY,CAAC,KAAK;gBAC7B,KAAK;gBACL,MAAM;gBACN,YAAY;gBACZ,KAAK,EAAE,GAAG,CAAC,WAAW;aACvB,CAAC,CAAC;YACH,IAAI,EAAE,CAAC;QACT,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,GAAG,CAAC,CAAC;QACZ,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACI,KAAK,UAAU,cAAc,CAClC,OAAmC,EACnC,MAAY,IAAI,IAAI,EAAE;IAEtB,MAAM,MAAM,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAiB,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACxF,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAe,EAAE,CAC7C,IAAI,KAAK,uBAAS,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAE/F,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,QAAQ,CAAC,IAAA,2BAAa,EAAC,2BAAa,CAAC,CAAC,aAAa,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAC9B,MAAM,CAAC,GAAG,CAAC,KAAK,EAAC,KAAK,EAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,MAAM,IAAA,wBAAe,EAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,CACzF,CAAC;IAEF,4EAA4E;IAC5E,yCAAyC;IACzC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,KAAK,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtD,OAAQ,MAAiB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC9D,CAAC"}
|
package/package.json
CHANGED
|
@@ -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
|
|
112
|
+
* The oldest timestamp a caller may read, or `null` for unlimited history.
|
|
113
113
|
*
|
|
114
|
-
* Meant to be dropped straight into
|
|
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(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
}
|