webspresso 0.0.93 → 0.0.94

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.
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * polar:migrate — write Polar billing migration to migrations/
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { loadDbConfig } = require('../utils/db');
10
+ const { generatePolarMigration } = require('../../plugins/polar/src/migration');
11
+
12
+ function registerCommand(program) {
13
+ program
14
+ .command('polar:migrate')
15
+ .description('Generate idempotent Polar billing migration for users table')
16
+ .option('-c, --config <path>', 'Path to database config file')
17
+ .option('-t, --table <name>', 'User table name', 'users')
18
+ .option('-o, --output <path>', 'Output migration file path (default: migrations/<timestamp>_polar_billing.js)')
19
+ .action(async (options) => {
20
+ const { config, path: configPath } = loadDbConfig(options.config);
21
+ console.log(`\nšŸ“¦ Using config: ${configPath}\n`);
22
+
23
+ const migrationDir = config.migrations?.directory || './migrations';
24
+ if (!fs.existsSync(migrationDir)) {
25
+ fs.mkdirSync(migrationDir, { recursive: true });
26
+ console.log(`Created directory: ${migrationDir}`);
27
+ }
28
+
29
+ let filepath = options.output;
30
+ if (!filepath) {
31
+ const now = new Date();
32
+ const timestamp = [
33
+ now.getFullYear(),
34
+ String(now.getMonth() + 1).padStart(2, '0'),
35
+ String(now.getDate()).padStart(2, '0'),
36
+ String(now.getHours()).padStart(2, '0'),
37
+ String(now.getMinutes()).padStart(2, '0'),
38
+ String(now.getSeconds()).padStart(2, '0'),
39
+ ].join('');
40
+ filepath = path.join(migrationDir, `${timestamp}_polar_billing.js`);
41
+ }
42
+
43
+ const content = generatePolarMigration({ tableName: options.table });
44
+ fs.writeFileSync(filepath, content, 'utf8');
45
+ console.log(`āœ… Polar migration written: ${filepath}`);
46
+ console.log('\nNext: webspresso db:migrate\n');
47
+ });
48
+ }
49
+
50
+ module.exports = { registerCommand };
package/bin/webspresso.js CHANGED
@@ -35,6 +35,7 @@ const { registerCommand: registerUpgrade } = require('./commands/upgrade');
35
35
  const { registerCommand: registerOrmMap } = require('./commands/orm-map');
36
36
  const { registerCommand: registerService } = require('./commands/service');
37
37
  const { registerCommand: registerMcp } = require('./commands/mcp');
38
+ const { registerCommand: registerPolarMigrate } = require('./commands/polar-migrate');
38
39
 
39
40
  registerNew(program);
40
41
  registerPage(program);
@@ -58,6 +59,7 @@ registerUpgrade(program);
58
59
  registerOrmMap(program);
59
60
  registerService(program);
60
61
  registerMcp(program);
62
+ registerPolarMigrate(program);
61
63
 
62
64
  // Parse arguments
63
65
  program.parse();
@@ -69,6 +69,9 @@ function createKnexAuthTokensAdapter(db) {
69
69
  if (type) q.where({ type });
70
70
  await q.delete();
71
71
  },
72
+ purgeExpired: async () => {
73
+ return knex('auth_tokens').where('expires_at', '<', new Date()).delete();
74
+ },
72
75
  };
73
76
  }
74
77
 
@@ -123,6 +126,18 @@ async function consumeAuthToken(authTokensAdapter, tokenHash) {
123
126
  await authTokensAdapter.delete(tokenHash);
124
127
  }
125
128
 
129
+ /**
130
+ * Purge expired auth tokens
131
+ * @param {Object} authTokensAdapter
132
+ * @returns {Promise<number>} Number of deleted rows
133
+ */
134
+ async function purgeExpiredAuthTokens(authTokensAdapter) {
135
+ if (authTokensAdapter && typeof authTokensAdapter.purgeExpired === 'function') {
136
+ return authTokensAdapter.purgeExpired();
137
+ }
138
+ return 0;
139
+ }
140
+
126
141
  module.exports = {
127
142
  TOKEN_TYPES,
128
143
  createAuthTokensTable,
@@ -131,5 +146,6 @@ module.exports = {
131
146
  createAuthToken,
132
147
  verifyAuthToken,
133
148
  consumeAuthToken,
149
+ purgeExpiredAuthTokens,
134
150
  hash,
135
151
  };
@@ -9,7 +9,7 @@ const { hashKey, serializeWheres } = require('./fingerprint');
9
9
  * @param {*} knex
10
10
  */
11
11
  function isTransactionKnex(knex) {
12
- return !!(knex && knex.isTransaction);
12
+ return !!(knex && (knex.isTransaction || typeof knex.commit === 'function'));
13
13
  }
14
14
 
15
15
  /**
package/core/orm/index.js CHANGED
@@ -275,7 +275,10 @@ function createDatabase(config) {
275
275
  * @returns {Promise<*>} Result of callback
276
276
  */
277
277
  async function transaction(callback, scopeContext) {
278
- return knexInstance.transaction(async (trx) => {
278
+ const ambientTrx = getAmbientTransaction();
279
+ const runner = (ambientTrx && typeof ambientTrx.transaction === 'function') ? ambientTrx : knexInstance;
280
+
281
+ return runner.transaction(async (trx) => {
279
282
  const trxContext = {
280
283
  trx,
281
284
  getRepository(modelName, sContext) {
@@ -87,7 +87,9 @@ function createTransactionContext(trx, scopeContext) {
87
87
  * @returns {Promise<*>} Result of callback
88
88
  */
89
89
  async function runTransaction(knex, callback, scopeContext) {
90
- return knex.transaction(async (trx) => {
90
+ const ambientTrx = getAmbientTransaction();
91
+ const runner = (ambientTrx && typeof ambientTrx.transaction === 'function') ? ambientTrx : knex;
92
+ return runner.transaction(async (trx) => {
91
93
  const ctx = createTransactionContext(trx, scopeContext);
92
94
  return runWithAmbientTransaction(trx, () => callback(ctx));
93
95
  });
@@ -17,7 +17,9 @@ const { Readable } = require('stream');
17
17
  * @returns {Readable}
18
18
  */
19
19
  function createHtmlStream(options = {}) {
20
- const { env, templatePath, context = {}, defer = {} } = options;
20
+ const { env, templatePath, context = {}, defer = {}, nonce: optionNonce } = options;
21
+ const nonce = optionNonce || context.nonce || context.cspNonce || '';
22
+ const scriptTag = nonce ? `<script nonce="${nonce}">` : '<script>';
21
23
 
22
24
  if (!env) {
23
25
  throw new Error('createHtmlStream requires a valid Nunjucks environment in options.env');
@@ -63,6 +65,9 @@ function createHtmlStream(options = {}) {
63
65
 
64
66
  try {
65
67
  const resolvedData = await promise;
68
+ if (stream.destroyed) {
69
+ return;
70
+ }
66
71
  let slotHtml = '';
67
72
  if (slotTemplate) {
68
73
  slotHtml = env.render(slotTemplate, { ...context, [key]: resolvedData });
@@ -73,7 +78,7 @@ function createHtmlStream(options = {}) {
73
78
  // Push an out-of-order streaming chunk with inline replacement script or comment
74
79
  const slotChunk = `
75
80
  <template data-stream-slot="${key}">${slotHtml}</template>
76
- <script>
81
+ ${scriptTag}
77
82
  (function(){
78
83
  var t = document.querySelector('template[data-stream-slot="${key}"]');
79
84
  var target = document.querySelector('[data-stream-target="${key}"]') || document.getElementById('stream-target-${key}');
@@ -83,18 +88,27 @@ function createHtmlStream(options = {}) {
83
88
  }
84
89
  })();
85
90
  </script>`;
86
- stream.push(slotChunk);
91
+ if (!stream.destroyed) {
92
+ stream.push(slotChunk);
93
+ }
87
94
  } catch (slotErr) {
95
+ if (stream.destroyed) {
96
+ return;
97
+ }
88
98
  const errorChunk = `
89
99
  <template data-stream-slot="${key}">
90
100
  <!-- Slot Error (${key}): ${slotErr.message} -->
91
101
  </template>`;
92
- stream.push(errorChunk);
102
+ if (!stream.destroyed) {
103
+ stream.push(errorChunk);
104
+ }
93
105
  }
94
106
  });
95
107
 
96
108
  await Promise.all(resolutionPromises);
97
- stream.push(null); // End of stream
109
+ if (!stream.destroyed) {
110
+ stream.push(null); // End of stream
111
+ }
98
112
  } catch (err) {
99
113
  stream.destroy(err);
100
114
  }
@@ -122,8 +136,17 @@ function renderStream(res, templatePath, context = {}, options = {}) {
122
136
  defer = {},
123
137
  status = 200,
124
138
  flushImmediately = true,
139
+ nonce: optionNonce,
125
140
  } = options;
126
141
 
142
+ const nonce =
143
+ optionNonce ||
144
+ res?.locals?.cspNonce ||
145
+ res?.locals?.nonce ||
146
+ context?.cspNonce ||
147
+ context?.nonce ||
148
+ '';
149
+
127
150
  if (!env) {
128
151
  return reject(new Error('renderStream requires a valid Nunjucks environment in options.env'));
129
152
  }
@@ -145,25 +168,46 @@ function renderStream(res, templatePath, context = {}, options = {}) {
145
168
  templatePath,
146
169
  context,
147
170
  defer,
171
+ nonce,
148
172
  });
149
173
 
174
+ let finished = false;
175
+ const cleanup = () => {
176
+ if (finished) return;
177
+ finished = true;
178
+ if (!stream.destroyed) {
179
+ stream.destroy();
180
+ }
181
+ resolve();
182
+ };
183
+
184
+ res.on('close', cleanup);
185
+
150
186
  stream.on('data', (chunk) => {
151
- res.write(chunk);
187
+ if (!finished && !res.writableEnded) {
188
+ res.write(chunk);
189
+ }
152
190
  });
153
191
 
154
192
  stream.on('end', () => {
155
- res.end();
156
- resolve();
193
+ if (!finished) {
194
+ finished = true;
195
+ res.end();
196
+ resolve();
197
+ }
157
198
  });
158
199
 
159
200
  stream.on('error', (err) => {
160
- if (!res.headersSent) {
161
- res.status(500).send('Streaming Render Error');
162
- } else {
163
- res.write(`<!-- Streaming Error: ${err.message} -->`);
164
- res.end();
201
+ if (!finished) {
202
+ finished = true;
203
+ if (!res.headersSent) {
204
+ res.status(500).send('Streaming Render Error');
205
+ } else if (!res.writableEnded) {
206
+ res.write(`<!-- Streaming Error: ${err.message} -->`);
207
+ res.end();
208
+ }
209
+ reject(err);
165
210
  }
166
- reject(err);
167
211
  });
168
212
  });
169
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -68,6 +68,9 @@ async function verifyPassword(password, hash, compare) {
68
68
  return await compare(password, hash);
69
69
  }
70
70
 
71
+ // Precomputed dummy bcrypt hash (cost factor 10) to mitigate timing attacks / user enumeration
72
+ const DUMMY_HASH = '$2b$10$wKzS8Vp0qXn8mY2bQv5eUu6R.kZbN8vA8Y7o7u4d1w2x3y4z5a6b7';
73
+
71
74
  /**
72
75
  * Login user and set session
73
76
  * @param {Object} adminUserRepo - AdminUser repository
@@ -81,6 +84,13 @@ async function login(adminUserRepo, email, password, compare) {
81
84
  const user = await adminUserRepo.query().where('email', email).first();
82
85
 
83
86
  if (!user || !user.active) {
87
+ if (typeof compare === 'function') {
88
+ try {
89
+ await Promise.resolve(compare(password, DUMMY_HASH));
90
+ } catch {
91
+ // Ignore timing dummy comparison errors
92
+ }
93
+ }
84
94
  return null;
85
95
  }
86
96
 
@@ -59,12 +59,14 @@ function contentPlugin(options = {}) {
59
59
  if (!inlineEdit || !isAdminSession(req, adminPath)) {
60
60
  return html;
61
61
  }
62
+ const nonce = req.res?.locals?.cspNonce || req.res?.locals?.nonce || req.cspNonce || '';
63
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
62
64
  const configScript =
63
- '<script>window.__WS_CONTENT__=' +
65
+ `<script${nonceAttr}>window.__WS_CONTENT__=` +
64
66
  JSON.stringify({ enabled: true, adminPath }) +
65
67
  ';</script>';
66
- const styleTag = '<style id="ws-content-inline-css">' + INLINE_EDIT_CSS + '</style>';
67
- const scriptTag = '<script id="ws-content-inline-edit">' + INLINE_EDIT_JS + '</script>';
68
+ const styleTag = `<style id="ws-content-inline-css"${nonceAttr}>` + INLINE_EDIT_CSS + '</style>';
69
+ const scriptTag = `<script id="ws-content-inline-edit"${nonceAttr}>` + INLINE_EDIT_JS + '</script>';
68
70
  const bundle = configScript + styleTag + scriptTag;
69
71
  if (html.includes('</body>')) {
70
72
  return html.replace('</body>', bundle + '</body>');
@@ -251,6 +251,13 @@ function createImportHandler(opts) {
251
251
  return res.status(400).json({ error: `Too many data rows (${objects.length}). Limit is ${maxRows}.` });
252
252
  }
253
253
 
254
+ const isAtomic =
255
+ req.query.atomic === 'true' ||
256
+ req.query.atomic === '1' ||
257
+ req.body?.atomic === 'true' ||
258
+ req.body?.atomic === true ||
259
+ opts?.atomic === true;
260
+
254
261
  const repo = db.getRepository(model.name);
255
262
  const summary = {
256
263
  success: true,
@@ -260,52 +267,82 @@ function createImportHandler(opts) {
260
267
  errors: [],
261
268
  };
262
269
 
263
- for (const { rowNumber, data } of objects) {
264
- try {
265
- const payload = buildPayloadForRow(model, data, mode);
266
- if (Object.keys(payload).length === 0) {
267
- summary.failed++;
268
- summary.errors.push({ row: rowNumber, message: 'No mappable columns' });
269
- continue;
270
- }
270
+ const runRowProcessing = async (targetRepo) => {
271
+ for (const { rowNumber, data } of objects) {
272
+ try {
273
+ const payload = buildPayloadForRow(model, data, mode);
274
+ if (Object.keys(payload).length === 0) {
275
+ summary.failed++;
276
+ const errMsg = 'No mappable columns';
277
+ summary.errors.push({ row: rowNumber, message: errMsg });
278
+ if (isAtomic) {
279
+ throw new Error(`Row ${rowNumber}: ${errMsg}`);
280
+ }
281
+ continue;
282
+ }
271
283
 
272
- if (mode === 'insert') {
273
- await repo.create(payload);
274
- summary.created++;
275
- continue;
276
- }
284
+ if (mode === 'insert') {
285
+ await targetRepo.create(payload);
286
+ summary.created++;
287
+ continue;
288
+ }
277
289
 
278
- const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
279
- let keyVal;
280
- try {
281
- keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
290
+ const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
291
+ let keyVal;
292
+ try {
293
+ keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
294
+ } catch (e) {
295
+ summary.failed++;
296
+ summary.errors.push({ row: rowNumber, message: e.message || 'Bad upsert key' });
297
+ if (isAtomic) throw e;
298
+ continue;
299
+ }
300
+
301
+ if (keyVal === undefined || keyVal === null) {
302
+ const insertPayload = { ...payload };
303
+ delete insertPayload[upsertKey];
304
+ await targetRepo.create(insertPayload);
305
+ summary.created++;
306
+ continue;
307
+ }
308
+
309
+ const existing = await targetRepo.findOne({ [upsertKey]: keyVal });
310
+ if (existing) {
311
+ const id = existing[model.primaryKey];
312
+ await targetRepo.update(id, payload);
313
+ summary.updated++;
314
+ } else {
315
+ await targetRepo.create({ ...payload, [upsertKey]: keyVal });
316
+ summary.created++;
317
+ }
282
318
  } catch (e) {
283
319
  summary.failed++;
284
- summary.errors.push({ row: rowNumber, message: e.message || 'Bad upsert key' });
285
- continue;
286
- }
287
-
288
- if (keyVal === undefined || keyVal === null) {
289
- const insertPayload = { ...payload };
290
- delete insertPayload[upsertKey];
291
- await repo.create(insertPayload);
292
- summary.created++;
293
- continue;
320
+ summary.errors.push({ row: rowNumber, message: e.message || String(e) });
321
+ if (isAtomic) {
322
+ throw e;
323
+ }
294
324
  }
325
+ }
326
+ };
295
327
 
296
- const existing = await repo.findOne({ [upsertKey]: keyVal });
297
- if (existing) {
298
- const id = existing[model.primaryKey];
299
- await repo.update(id, payload);
300
- summary.updated++;
301
- } else {
302
- await repo.create({ ...payload, [upsertKey]: keyVal });
303
- summary.created++;
304
- }
305
- } catch (e) {
306
- summary.failed++;
307
- summary.errors.push({ row: rowNumber, message: e.message || String(e) });
328
+ if (isAtomic && typeof db.transaction === 'function') {
329
+ try {
330
+ await db.transaction(async (trxContext) => {
331
+ const trxRepo = trxContext.getRepository ? trxContext.getRepository(model.name) : repo;
332
+ await runRowProcessing(trxRepo);
333
+ });
334
+ } catch (txErr) {
335
+ return res.status(400).json({
336
+ success: false,
337
+ created: 0,
338
+ updated: 0,
339
+ failed: objects.length,
340
+ error: `Atomic import rolled back: ${txErr.message}`,
341
+ errors: summary.errors,
342
+ });
308
343
  }
344
+ } else {
345
+ await runRowProcessing(repo);
309
346
  }
310
347
 
311
348
  summary.success = summary.failed === 0;
package/plugins/index.js CHANGED
@@ -40,6 +40,7 @@ const {
40
40
  createRedisAdapter,
41
41
  } = require('./realtime');
42
42
  const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
43
+ const polarPlugin = require('./polar');
43
44
 
44
45
  module.exports = {
45
46
  sitemapPlugin,
@@ -81,6 +82,7 @@ module.exports = {
81
82
  mcpPlugin,
82
83
  createMcpServer,
83
84
  McpServer,
85
+ polarPlugin,
84
86
  };
85
87
 
86
88
 
@@ -44,6 +44,19 @@ function mountSseTransport(options) {
44
44
  const sessionId = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
45
45
  const endpointUri = `${normalizedBase}/messages?sessionId=${sessionId}`;
46
46
 
47
+ let isCleanedUp = false;
48
+ let keepAlive = null;
49
+
50
+ const cleanup = () => {
51
+ if (isCleanedUp) return;
52
+ isCleanedUp = true;
53
+ if (keepAlive) {
54
+ clearInterval(keepAlive);
55
+ keepAlive = null;
56
+ }
57
+ sessions.delete(sessionId);
58
+ };
59
+
47
60
  const sessionObj = {
48
61
  id: sessionId,
49
62
  req,
@@ -54,6 +67,7 @@ function mountSseTransport(options) {
54
67
  if (typeof res.flush === 'function') res.flush();
55
68
  } catch (err) {
56
69
  console.warn(`[mcp-sse] Failed to write to session ${sessionId}:`, err.message);
70
+ cleanup();
57
71
  }
58
72
  },
59
73
  };
@@ -67,18 +81,21 @@ function mountSseTransport(options) {
67
81
  }
68
82
 
69
83
  // Ping interval to keep connection alive through proxies
70
- const keepAlive = setInterval(() => {
84
+ keepAlive = setInterval(() => {
71
85
  try {
72
86
  res.write(': ping\n\n');
73
87
  } catch (e) {
74
- clearInterval(keepAlive);
88
+ cleanup();
75
89
  }
76
90
  }, 15000);
91
+ if (typeof keepAlive.unref === 'function') {
92
+ keepAlive.unref();
93
+ }
77
94
 
78
- req.on('close', () => {
79
- clearInterval(keepAlive);
80
- sessions.delete(sessionId);
81
- });
95
+ req.on('close', cleanup);
96
+ res.on('close', cleanup);
97
+ res.on('finish', cleanup);
98
+ res.on('error', cleanup);
82
99
  });
83
100
 
84
101
  // 2. POST /_mcp/messages - MCP message ingestion for an active SSE session
@@ -0,0 +1,12 @@
1
+ # Polar.sh billing (webspresso polar plugin)
2
+ POLAR_ACCESS_TOKEN=polar_oat_...
3
+ POLAR_WEBHOOK_SECRET=whsec_...
4
+ POLAR_PRO_PRODUCT_ID=
5
+ POLAR_ORG_SLUG=
6
+ POLAR_SANDBOX=false
7
+ BASE_URL=https://example.com
8
+
9
+ # Optional hosted checkout fallback (https://buy.polar.sh/polar_cl_...)
10
+ POLAR_CHECKOUT_URL=
11
+ POLAR_PRO_PRICE_ID=
12
+ POLAR_PRO_YEARLY_PRODUCT_ID=
@@ -0,0 +1,38 @@
1
+ # Changelog — `webspresso/plugins/polar`
2
+
3
+ ## 1.0.0 — 2026-09-10
4
+
5
+ First production-ready release.
6
+
7
+ ### Added
8
+
9
+ - Zero-dependency Polar SDK (`crypto` + `fetch`): webhook verification, API client, checkout, portal, Customer State sync
10
+ - Auto routes: webhook, checkout (GET/POST), portal, billing status JSON API
11
+ - Idempotent migration generator + `webspresso polar:migrate` CLI
12
+ - Configurable field mapping, tier/plan mapping, hooks (`onSubscriptionChange`)
13
+ - Optional `polarSyncMiddleware` for page-load tier refresh
14
+ - Nunjucks `dateLabel` filter + Date-safe `truncate` wrapper
15
+ - CSP helper (`polarCspDirectives`) for checkout form redirects
16
+ - Zod env schema export (`parseEnv`, `polarEnvSchema`)
17
+
18
+ ### Production lessons encoded
19
+
20
+ | Issue | Fix |
21
+ |-------|-----|
22
+ | Checkout URL is often `https://polar.sh/checkout/polar_cst_*`, not only `buy.polar.sh` | `extractCheckoutUrlFromResponse` + `client_secret` builder |
23
+ | `https://polar.sh/purchases/subscriptions` → 404 | Blocked in URL validators |
24
+ | `subscription.created` with `incomplete` status | Fallback Customer State sync before granting Pro |
25
+ | Metadata may not propagate to subscription | `external_customer_id` required on checkout; multi-strategy user resolver |
26
+ | `polar_current_period_end` as Date breaks Nunjucks `truncate` | Always persist ISO strings; `dateLabel` + safe truncate |
27
+ | Checkout "already subscribed" API error | Pre-checkout `syncPolarBillingForUser` + early redirect |
28
+ | Webhook 401 | Standard Webhooks HMAC (`webhook-id`, `webhook-timestamp`, `whsec_` decode); document raw body requirement |
29
+ | Sandbox vs production OAT | `POLAR_SANDBOX=true` → `sandbox-api.polar.sh` |
30
+ | Empty metadata fields → Polar 422 | `buildCheckoutMetadata` omits blank values |
31
+ | Portal deprecated paths | Validate `/{org}/portal` only; fallback `POST /v1/customer-sessions/` |
32
+
33
+ ### Migration notes for existing custom Polar integrations
34
+
35
+ - Remove local `utils/polar.js` and file routes under billing/webhook paths — plugin registers equivalent routes
36
+ - Run `webspresso polar:migrate` (or `plugin.api.generateMigration()`) once
37
+ - Mount `createWebhookRawBodyMiddleware('/api/v1/polar/webhook')` **before** `express.json()` in custom servers for strict signature verification
38
+ - Wire `hooks.onSubscriptionChange` if tier updates need side effects (limits, analytics, etc.)