@teleporthq/teleport-shared 0.43.67 → 0.43.69

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 (107) hide show
  1. package/__tests__/_helpers/embed-runtime-emitter.ts +197 -0
  2. package/__tests__/utils/discount-engine.ts +1548 -0
  3. package/__tests__/utils/product-options.ts +2458 -0
  4. package/__tests__/utils/rich-text-embeds-runtime-module.ts +34 -0
  5. package/__tests__/utils/subscription-access.ts +37 -0
  6. package/dist/cjs/index.d.ts +9 -1
  7. package/dist/cjs/index.d.ts.map +1 -1
  8. package/dist/cjs/index.js +17 -1
  9. package/dist/cjs/index.js.map +1 -1
  10. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  11. package/dist/cjs/utils/cart-currency.d.ts +27 -0
  12. package/dist/cjs/utils/cart-currency.d.ts.map +1 -0
  13. package/dist/cjs/utils/cart-currency.js +31 -0
  14. package/dist/cjs/utils/cart-currency.js.map +1 -0
  15. package/dist/cjs/utils/cart-line-pricing.d.ts +25 -0
  16. package/dist/cjs/utils/cart-line-pricing.d.ts.map +1 -0
  17. package/dist/cjs/utils/cart-line-pricing.js +29 -0
  18. package/dist/cjs/utils/cart-line-pricing.js.map +1 -0
  19. package/dist/cjs/utils/discount-engine.d.ts +74 -0
  20. package/dist/cjs/utils/discount-engine.d.ts.map +1 -0
  21. package/dist/cjs/utils/discount-engine.js +435 -0
  22. package/dist/cjs/utils/discount-engine.js.map +1 -0
  23. package/dist/cjs/utils/product-options.d.ts +25 -0
  24. package/dist/cjs/utils/product-options.d.ts.map +1 -0
  25. package/dist/cjs/utils/product-options.js +1155 -0
  26. package/dist/cjs/utils/product-options.js.map +1 -0
  27. package/dist/cjs/utils/rich-text-embeds-codegen.d.ts +13 -0
  28. package/dist/cjs/utils/rich-text-embeds-codegen.d.ts.map +1 -1
  29. package/dist/cjs/utils/rich-text-embeds-codegen.js +15 -104
  30. package/dist/cjs/utils/rich-text-embeds-codegen.js.map +1 -1
  31. package/dist/cjs/utils/rich-text-embeds-runtime-source.d.ts +2 -0
  32. package/dist/cjs/utils/rich-text-embeds-runtime-source.d.ts.map +1 -0
  33. package/dist/cjs/utils/rich-text-embeds-runtime-source.js +409 -0
  34. package/dist/cjs/utils/rich-text-embeds-runtime-source.js.map +1 -0
  35. package/dist/cjs/utils/session-cookie-resolver.d.ts +85 -0
  36. package/dist/cjs/utils/session-cookie-resolver.d.ts.map +1 -0
  37. package/dist/cjs/utils/session-cookie-resolver.js +108 -0
  38. package/dist/cjs/utils/session-cookie-resolver.js.map +1 -0
  39. package/dist/cjs/utils/subscription-access.d.ts +36 -0
  40. package/dist/cjs/utils/subscription-access.d.ts.map +1 -0
  41. package/dist/cjs/utils/subscription-access.js +61 -0
  42. package/dist/cjs/utils/subscription-access.js.map +1 -0
  43. package/dist/cjs/utils/table-access.d.ts +114 -0
  44. package/dist/cjs/utils/table-access.d.ts.map +1 -0
  45. package/dist/cjs/utils/table-access.js +238 -0
  46. package/dist/cjs/utils/table-access.js.map +1 -0
  47. package/dist/cjs/utils/web-push.d.ts +43 -0
  48. package/dist/cjs/utils/web-push.d.ts.map +1 -0
  49. package/dist/cjs/utils/web-push.js +62 -0
  50. package/dist/cjs/utils/web-push.js.map +1 -0
  51. package/dist/esm/index.d.ts +9 -1
  52. package/dist/esm/index.d.ts.map +1 -1
  53. package/dist/esm/index.js +9 -1
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  56. package/dist/esm/utils/cart-currency.d.ts +27 -0
  57. package/dist/esm/utils/cart-currency.d.ts.map +1 -0
  58. package/dist/esm/utils/cart-currency.js +27 -0
  59. package/dist/esm/utils/cart-currency.js.map +1 -0
  60. package/dist/esm/utils/cart-line-pricing.d.ts +25 -0
  61. package/dist/esm/utils/cart-line-pricing.d.ts.map +1 -0
  62. package/dist/esm/utils/cart-line-pricing.js +25 -0
  63. package/dist/esm/utils/cart-line-pricing.js.map +1 -0
  64. package/dist/esm/utils/discount-engine.d.ts +74 -0
  65. package/dist/esm/utils/discount-engine.d.ts.map +1 -0
  66. package/dist/esm/utils/discount-engine.js +431 -0
  67. package/dist/esm/utils/discount-engine.js.map +1 -0
  68. package/dist/esm/utils/product-options.d.ts +25 -0
  69. package/dist/esm/utils/product-options.d.ts.map +1 -0
  70. package/dist/esm/utils/product-options.js +1151 -0
  71. package/dist/esm/utils/product-options.js.map +1 -0
  72. package/dist/esm/utils/rich-text-embeds-codegen.d.ts +13 -0
  73. package/dist/esm/utils/rich-text-embeds-codegen.d.ts.map +1 -1
  74. package/dist/esm/utils/rich-text-embeds-codegen.js +16 -105
  75. package/dist/esm/utils/rich-text-embeds-codegen.js.map +1 -1
  76. package/dist/esm/utils/rich-text-embeds-runtime-source.d.ts +2 -0
  77. package/dist/esm/utils/rich-text-embeds-runtime-source.d.ts.map +1 -0
  78. package/dist/esm/utils/rich-text-embeds-runtime-source.js +406 -0
  79. package/dist/esm/utils/rich-text-embeds-runtime-source.js.map +1 -0
  80. package/dist/esm/utils/session-cookie-resolver.d.ts +85 -0
  81. package/dist/esm/utils/session-cookie-resolver.d.ts.map +1 -0
  82. package/dist/esm/utils/session-cookie-resolver.js +103 -0
  83. package/dist/esm/utils/session-cookie-resolver.js.map +1 -0
  84. package/dist/esm/utils/subscription-access.d.ts +36 -0
  85. package/dist/esm/utils/subscription-access.d.ts.map +1 -0
  86. package/dist/esm/utils/subscription-access.js +57 -0
  87. package/dist/esm/utils/subscription-access.js.map +1 -0
  88. package/dist/esm/utils/table-access.d.ts +114 -0
  89. package/dist/esm/utils/table-access.d.ts.map +1 -0
  90. package/dist/esm/utils/table-access.js +233 -0
  91. package/dist/esm/utils/table-access.js.map +1 -0
  92. package/dist/esm/utils/web-push.d.ts +43 -0
  93. package/dist/esm/utils/web-push.d.ts.map +1 -0
  94. package/dist/esm/utils/web-push.js +59 -0
  95. package/dist/esm/utils/web-push.js.map +1 -0
  96. package/package.json +3 -3
  97. package/src/index.ts +16 -0
  98. package/src/utils/cart-currency.ts +76 -0
  99. package/src/utils/cart-line-pricing.ts +151 -0
  100. package/src/utils/discount-engine.ts +437 -0
  101. package/src/utils/product-options.ts +1150 -0
  102. package/src/utils/rich-text-embeds-codegen.ts +16 -183
  103. package/src/utils/rich-text-embeds-runtime-source.ts +405 -0
  104. package/src/utils/session-cookie-resolver.ts +305 -0
  105. package/src/utils/subscription-access.ts +59 -0
  106. package/src/utils/table-access.ts +516 -0
  107. package/src/utils/web-push.ts +61 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Resolving the NextAuth session token in a generated route, WITHOUT asking
3
+ * `process.env.NEXTAUTH_URL` what the session cookie is called.
4
+ *
5
+ * ## ⛔ THE REPORTED DEFECT
6
+ *
7
+ * A published project showed "Failed to load dashboard data. Please refresh."
8
+ * on the home page, "Failed to load meeting data." on the list page and "could
9
+ * not fetch the templates" on a third — for a user who was signed in, on a page
10
+ * whose own `/api/auth/session` returned that user. The failing call was the
11
+ * page-load workflow's server segment, answering **401 `Unauthenticated`** from
12
+ * `guardWorkflowRequest`. Reloading sometimes fixed it; fanning 50 concurrent
13
+ * requests at the same route returned **33 × 401 and 17 × 200**, while 40
14
+ * concurrent `/api/auth/session` calls resolved the user 40/40.
15
+ *
16
+ * ## The mechanism
17
+ *
18
+ * next-auth v4's `getToken()` derives the cookie it reads from an env var:
19
+ *
20
+ * ```js
21
+ * secureCookie = process.env.NEXTAUTH_URL?.startsWith('https://') ?? !!process.env.VERCEL,
22
+ * cookieName = secureCookie ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
23
+ * ```
24
+ *
25
+ * A generated project ships `NEXTAUTH_URL=http://localhost:3000` — the build
26
+ * cannot know the domain it will be published to, so `pages/api/auth/[...nextauth].js`
27
+ * repairs `process.env.NEXTAUTH_URL` **from the incoming request** instead. That
28
+ * repair is process-local: it fixes the value for whichever serverless instance
29
+ * happened to serve an auth request, and for no other. Every other instance
30
+ * still holds `http://localhost:3000`, so `startsWith('https://')` is `false`
31
+ * (NOT `undefined` — the `??` fallback to `!!process.env.VERCEL` never runs),
32
+ * `getToken` looks for `next-auth.session-token`, the browser is holding
33
+ * `__Secure-next-auth.session-token`, nothing is found, and a signed-in user is
34
+ * reported as anonymous.
35
+ *
36
+ * Hence the coin-flip: it depends on whether the instance that served your
37
+ * request had previously served an auth request. Nothing about the session, the
38
+ * secret or the cookie is wrong.
39
+ *
40
+ * The same defect is why the generated middleware carries a `hasSessionCookie`
41
+ * fallback and a round trip to `/api/auth/session`, attributed there to an
42
+ * "Edge runtime quirk" — the Edge process holds that same localhost value.
43
+ *
44
+ * ## The rule
45
+ *
46
+ * ⭐ **A request must be authenticated from the REQUEST, never from an ambient
47
+ * env var that describes the deployment.** So this resolver reads which session
48
+ * cookie the caller actually sent, and tells `getToken` exactly that — falling
49
+ * back to the request's own protocol when the cookie header is unavailable.
50
+ *
51
+ * Emitted as plain ES5 text and inlined into each generated route: a route that
52
+ * carries its own copy cannot be broken by a missing sibling module or an
53
+ * untraced dependency, and every call site gets the identical implementation
54
+ * from this one definition. It lives in teleport-shared because both the
55
+ * workflow routes and the per-table data routes decode the session.
56
+ */
57
+
58
+ /** The `__tq`-prefixed globals the emitted snippet declares. */
59
+ export const SESSION_TOKEN_RESOLVER_FN = '__tqResolveSessionToken'
60
+
61
+ /**
62
+ * Session-cookie names next-auth/Auth.js writes, with the `secureCookie` flag
63
+ * that produces each. Order within a protocol is irrelevant — only ONE of these
64
+ * is ever present — but the https/http split decides which pair is tried first.
65
+ *
66
+ * `__Host-` is deliberately absent: next-auth uses it only for the CSRF and
67
+ * callback cookies, never for the session token.
68
+ */
69
+ const KNOWN_SESSION_COOKIES: ReadonlyArray<{ name: string; secure: boolean }> = [
70
+ { name: '__Secure-next-auth.session-token', secure: true },
71
+ { name: 'next-auth.session-token', secure: false },
72
+ { name: '__Secure-authjs.session-token', secure: true },
73
+ { name: 'authjs.session-token', secure: false },
74
+ ]
75
+
76
+ /**
77
+ * ES5 source for the shared session-token resolver, safe to inline into any
78
+ * generated CommonJS route or ESM middleware.
79
+ *
80
+ * Declares:
81
+ * - `__tqCookieNamesPresent(req)` — the set of cookie names on the request,
82
+ * parsed properly (a substring test would see `next-auth.session-token`
83
+ * inside `__Secure-next-auth.session-token` and pick the wrong one).
84
+ * - `__tqRequestIsSecure(req)` — https as the REQUEST reports it.
85
+ * - `__tqSessionTokenCandidates(req)` — `{cookieName, secureCookie}` pairs to try.
86
+ * - `__tqResolveSessionToken(getToken, req, secret)` — the token, or null.
87
+ *
88
+ * `getToken` is passed IN rather than required here, so the same body serves a
89
+ * CommonJS route (lazy `require('next-auth/jwt')`) and the Edge middleware
90
+ * (static `import`). Pure, total, never throws, no env reads.
91
+ */
92
+ export const generateSessionTokenResolverCode = (): string => {
93
+ return `// GENERATED — see generateSessionTokenResolverCode in
94
+ // @teleporthq/teleport-shared/src/utils/session-cookie-resolver.ts.
95
+ //
96
+ // next-auth's getToken() picks the session cookie name from
97
+ // process.env.NEXTAUTH_URL, which a generated project ships as a localhost
98
+ // default — so on a published https domain it looks for the non-secure cookie
99
+ // and reports a signed-in user as anonymous. These helpers read the cookie the
100
+ // request actually carries and tell getToken exactly which one it is.
101
+ var __TQ_SESSION_COOKIES = ${JSON.stringify(KNOWN_SESSION_COOKIES.map((c) => [c.name, c.secure]))};
102
+
103
+ // Every cookie NAME on the request. Parsed on the "; " separator rather than
104
+ // matched as a substring: "next-auth.session-token" occurs inside
105
+ // "__Secure-next-auth.session-token", so a substring test reports the plain
106
+ // cookie as present whenever the secure one is, and getToken then decodes the
107
+ // wrong (missing) cookie.
108
+ function __tqCookieNamesPresent(req) {
109
+ var present = {};
110
+ var raw = '';
111
+ try {
112
+ var headers = req && req.headers;
113
+ if (headers && typeof headers.get === 'function') {
114
+ // Edge / fetch Headers.
115
+ raw = headers.get('cookie') || '';
116
+ } else if (headers) {
117
+ var value = headers.cookie || headers.Cookie;
118
+ raw = Array.isArray(value) ? value.join('; ') : value || '';
119
+ }
120
+ } catch (e) {
121
+ raw = '';
122
+ }
123
+ var parts = String(raw).split(';');
124
+ for (var i = 0; i < parts.length; i++) {
125
+ var eq = parts[i].indexOf('=');
126
+ if (eq > 0) {
127
+ present[parts[i].slice(0, eq).trim()] = true;
128
+ }
129
+ }
130
+ // Second source: the runtime's own parsed cookies. A Next.js API route hands
131
+ // over a plain object; the Edge RequestCookies exposes getAll().
132
+ try {
133
+ var cookies = req && req.cookies;
134
+ if (cookies && typeof cookies.getAll === 'function') {
135
+ var all = cookies.getAll() || [];
136
+ for (var a = 0; a < all.length; a++) {
137
+ if (all[a] && all[a].name) {
138
+ present[all[a].name] = true;
139
+ }
140
+ }
141
+ } else if (cookies && typeof cookies === 'object') {
142
+ for (var key in cookies) {
143
+ if (Object.prototype.hasOwnProperty.call(cookies, key)) {
144
+ present[key] = true;
145
+ }
146
+ }
147
+ }
148
+ } catch (e) {
149
+ /* the header pass above is enough */
150
+ }
151
+ return present;
152
+ }
153
+
154
+ // Is this request https? Read from the proxy header the platform sets, then the
155
+ // socket, then the URL — never from an env var describing the deployment.
156
+ function __tqRequestIsSecure(req) {
157
+ try {
158
+ var headers = (req && req.headers) || {};
159
+ var proto =
160
+ typeof headers.get === 'function'
161
+ ? headers.get('x-forwarded-proto')
162
+ : headers['x-forwarded-proto'];
163
+ if (Array.isArray(proto)) {
164
+ proto = proto[0];
165
+ }
166
+ if (typeof proto === 'string' && proto) {
167
+ // A chain of proxies appends: "https,http" — the client-facing hop is first.
168
+ return proto.split(',')[0].trim().toLowerCase() === 'https';
169
+ }
170
+ if (req && req.nextUrl && req.nextUrl.protocol === 'https:') {
171
+ return true;
172
+ }
173
+ if (req && req.socket && req.socket.encrypted) {
174
+ return true;
175
+ }
176
+ if (req && req.connection && req.connection.encrypted) {
177
+ return true;
178
+ }
179
+ if (typeof (req && req.url) === 'string' && req.url.indexOf('https://') === 0) {
180
+ return true;
181
+ }
182
+ } catch (e) {
183
+ /* fall through to false */
184
+ }
185
+ return false;
186
+ }
187
+
188
+ // The { cookieName, secureCookie } pairs worth trying, most likely first.
189
+ // Normally exactly one cookie is present and this returns a single entry; the
190
+ // list only grows when a site has been reached over both http and https.
191
+ function __tqSessionTokenCandidates(req) {
192
+ var present = __tqCookieNamesPresent(req);
193
+ var secure = __tqRequestIsSecure(req);
194
+ var candidates = [];
195
+ for (var i = 0; i < __TQ_SESSION_COOKIES.length; i++) {
196
+ var name = __TQ_SESSION_COOKIES[i][0];
197
+ var isSecureName = __TQ_SESSION_COOKIES[i][1];
198
+ if (present[name] || present[name + '.0']) {
199
+ candidates.push({ cookieName: name, secureCookie: isSecureName });
200
+ }
201
+ }
202
+ candidates.sort(function (a, b) {
203
+ if (a.secureCookie === b.secureCookie) {
204
+ return 0;
205
+ }
206
+ return a.secureCookie === secure ? -1 : 1;
207
+ });
208
+ if (candidates.length === 0) {
209
+ // No cookie we recognise. The project may configure a custom cookie name in
210
+ // authOptions, so hand getToken the request's own protocol and let it apply
211
+ // its defaults — same behaviour as before this resolver existed, minus the
212
+ // localhost env var.
213
+ candidates.push({ secureCookie: secure });
214
+ }
215
+ return candidates;
216
+ }
217
+
218
+ // When a browser carries BOTH session cookies, both usually decode — they are
219
+ // two snapshots of the same account written at different times (NextAuth
220
+ // switches between the plain and the "__Secure-" name with the origin it
221
+ // derives, and never clears the other one). The one NextAuth is maintaining is
222
+ // re-issued on every session read, so it is the most recently issued; the
223
+ // other is frozen at whatever the account looked like when NextAuth last used
224
+ // that name, role included. Trusting it by protocol alone made a promoted admin
225
+ // read as "user" at the edge while /api/auth/session said "admin".
226
+ function __tqTokenIssuedAt(token) {
227
+ var issued = token && typeof token === 'object' ? token.iat : null;
228
+ return typeof issued === 'number' && isFinite(issued) ? issued : 0;
229
+ }
230
+
231
+ // The decoded session token, or null. Decodes every candidate cookie (normally
232
+ // exactly one) and keeps the most recently issued, so a project that renamed
233
+ // its cookie, or one reached over both protocols, resolves to the session
234
+ // NextAuth is actually maintaining. A tie keeps the protocol-matching
235
+ // candidate. Never throws.
236
+ function ${SESSION_TOKEN_RESOLVER_FN}(getToken, req, secret) {
237
+ if (!secret || typeof getToken !== 'function') {
238
+ return Promise.resolve(null);
239
+ }
240
+ var candidates = __tqSessionTokenCandidates(req);
241
+ var decode = function (candidate) {
242
+ var params = { req: req, secret: secret, secureCookie: candidate.secureCookie };
243
+ if (candidate.cookieName) {
244
+ params.cookieName = candidate.cookieName;
245
+ }
246
+ return Promise.resolve()
247
+ .then(function () {
248
+ return getToken(params);
249
+ })
250
+ .catch(function () {
251
+ return null;
252
+ });
253
+ };
254
+ var decodes = [];
255
+ for (var i = 0; i < candidates.length; i++) {
256
+ decodes.push(decode(candidates[i]));
257
+ }
258
+ return Promise.all(decodes)
259
+ .then(function (tokens) {
260
+ var best = null;
261
+ for (var t = 0; t < tokens.length; t++) {
262
+ if (tokens[t] && (best === null || __tqTokenIssuedAt(tokens[t]) > __tqTokenIssuedAt(best))) {
263
+ best = tokens[t];
264
+ }
265
+ }
266
+ return best;
267
+ })
268
+ .catch(function () {
269
+ return null;
270
+ });
271
+ }
272
+ `
273
+ }
274
+
275
+ /**
276
+ * The resolver plus a lazy `require('next-auth/jwt')`, for a CommonJS route.
277
+ *
278
+ * The require is lazy and guarded because a project generated WITHOUT
279
+ * authentication does not ship `next-auth`; such a project also has no session
280
+ * to read, so resolving to `null` there is the correct answer rather than a
281
+ * module-load crash.
282
+ */
283
+ export const generateCommonJsSessionTokenResolverCode = (): string => {
284
+ return `${generateSessionTokenResolverCode()}
285
+ // Lazy + guarded: a project generated without authentication has no next-auth
286
+ // installed, and no session to read either — null is the right answer there.
287
+ function __tqGetTokenFn() {
288
+ try {
289
+ var jwt = require('next-auth/jwt');
290
+ return jwt && typeof jwt.getToken === 'function' ? jwt.getToken : null;
291
+ } catch (e) {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ // The session token for this request, or null. Reads NEXTAUTH_SECRET itself.
297
+ function __tqSessionToken(req) {
298
+ var secret = process.env.NEXTAUTH_SECRET;
299
+ if (!secret) {
300
+ return Promise.resolve(null);
301
+ }
302
+ return ${SESSION_TOKEN_RESOLVER_FN}(__tqGetTokenFn(), req, secret);
303
+ }
304
+ `
305
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Which subscription rows entitle a buyer to a subscriber-only page.
3
+ *
4
+ * `teleport_subscriptions.status` moves through `pending` (the first order is
5
+ * placed, the provider has not confirmed the mandate), `trialing`, `active`,
6
+ * `past_due` (a renewal charge failed and the provider retries), `paused`,
7
+ * `cancelled` and `expired`. A buyer keeps their content while the provider is
8
+ * still honouring the mandate — through a trial, an active period and the
9
+ * retry window of a failed charge. `pending` never confirmed, `paused` stopped
10
+ * collecting, and the two closed statuses are over. A row with
11
+ * `cancel_at_period_end` stays entitled: its status only turns `cancelled`
12
+ * once the provider ends it at the period end.
13
+ *
14
+ * The GUI keeps the same list as `ENTITLED_SUBSCRIPTION_STATUS_VALUES`
15
+ * (`features/e-commerce/constants/subscriptions.ts`), pinned by a parity spec.
16
+ */
17
+ export const SUBSCRIPTION_ENTITLED_STATUSES: ReadonlyArray<string> = [
18
+ 'trialing',
19
+ 'active',
20
+ 'past_due',
21
+ ]
22
+
23
+ export const SUBSCRIPTIONS_TABLE = 'teleport_subscriptions'
24
+
25
+ /**
26
+ * The one parameterised statement every entitlement check runs:
27
+ * $1 — the session user id (compared as text: auth ids are strings, the
28
+ * column is a uuid),
29
+ * $2 — the entitled statuses,
30
+ * $3 — the product ids the page names; an EMPTY array means any product.
31
+ *
32
+ * A subscription the STORE bills (a provider that cannot charge a saved
33
+ * payment method) carries `access_until`: the end of the paid period plus the
34
+ * grace for the next payment. Past it the row stops entitling on its own, so
35
+ * access ends on time even when the scheduled billing run is late or off.
36
+ * Read through `to_jsonb` so a store whose table predates the column still
37
+ * answers (a missing key reads NULL, which never limits).
38
+ */
39
+ export const SUBSCRIPTION_ENTITLEMENT_SQL =
40
+ `SELECT 1 FROM ${SUBSCRIPTIONS_TABLE} s ` +
41
+ 'WHERE s.user_id::text = $1 AND s.status = ANY($2::text[]) ' +
42
+ 'AND (cardinality($3::text[]) = 0 OR s.product_id::text = ANY($3::text[])) ' +
43
+ "AND ((to_jsonb(s) ->> 'access_until') IS NULL " +
44
+ "OR (to_jsonb(s) ->> 'access_until')::timestamp > NOW()) LIMIT 1"
45
+
46
+ /** Trimmed, de-duplicated, non-empty ids — whatever shape the caller had them in. */
47
+ export const normalizeSubscriptionProductIds = (value: unknown): string[] => {
48
+ const raw = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []
49
+ const seen = new Set<string>()
50
+ const ids: string[] = []
51
+ for (const entry of raw) {
52
+ const id = String(entry ?? '').trim()
53
+ if (id && !seen.has(id)) {
54
+ seen.add(id)
55
+ ids.push(id)
56
+ }
57
+ }
58
+ return ids
59
+ }