webspresso 0.0.94 → 0.0.95

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.94",
3
+ "version": "0.0.95",
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",
@@ -14,10 +14,21 @@
14
14
  "default": "./core/errors/index.js"
15
15
  },
16
16
  "./plugins": "./plugins/index.js",
17
- "./plugins/*": "./plugins/*",
17
+ "./plugins/polar": "./plugins/polar/index.js",
18
+ "./plugins/polar/src/*": "./plugins/polar/src/*.js",
19
+ "./plugins/*": "./plugins/*/index.js",
18
20
  "./realtime": "./core/realtime/index.js",
19
- "./core/*": "./core/*",
20
- "./src/*": "./src/*",
21
+ "./core/auth": "./core/auth/index.js",
22
+ "./core/auth/*": "./core/auth/*.js",
23
+ "./core/errors": "./core/errors/index.js",
24
+ "./core/orm": "./core/orm/index.js",
25
+ "./core/queue": "./core/queue/index.js",
26
+ "./core/*.js": "./core/*.js",
27
+ "./core/*": "./core/*/index.js",
28
+ "./core/*/*.js": "./core/*/*.js",
29
+ "./core/*/*": "./core/*/*/index.js",
30
+ "./core/*/*/*.js": "./core/*/*/*.js",
31
+ "./src/*": "./src/*.js",
21
32
  "./views/*": "./views/*",
22
33
  "./templates/*": "./templates/*",
23
34
  "./package.json": "./package.json"
@@ -1,5 +1,23 @@
1
1
  # Changelog — `webspresso/plugins/polar`
2
2
 
3
+ ## 1.0.1 — Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - **package exports**: `webspresso/core/auth`, `webspresso/plugins/polar`, and `webspresso/plugins/polar/src/*` resolve without manual `/index.js` suffix
8
+ - **Rate limiting**: Polar limiters use `express-rate-limit` v8 `ipKeyGenerator` (fixes production startup `ValidationError`)
9
+ - **Webhook user lookup**: `metadata.user_id` supports nanoid/UUID string primary keys
10
+ - **CSP**: `polarCspDirectives()` includes `'self'` in `formAction` and `connectSrc`
11
+
12
+ ### Added
13
+
14
+ - `hooks.isPaidUser({ user, knex, config })` for external tier tables (legacy `(user, knex, config)` still supported)
15
+ - `syncBeforeCheckout` option (default `true`)
16
+ - `polar.api.syncBillingForAppUser(user, knex, { hooks })` for dashboard page-load sync
17
+ - `mergePolarCspDirectives(existing)` for Helmet CSP union merge
18
+ - Production checklist: `docs/guides/polar-production-checklist.md`
19
+ - Integration tests: rate-limit startup, IPv6 key validation, `isPaidUser` hook
20
+
3
21
  ## 1.0.0 — 2026-09-10
4
22
 
5
23
  First production-ready release.
@@ -58,10 +58,19 @@ That's it — routes, webhooks, checkout, portal, and status API are auto-mounte
58
58
  | Method | Path | Auth | Behavior |
59
59
  |--------|------|------|----------|
60
60
  | POST | `/api/v1/polar/webhook` | HMAC signature | Process Polar events |
61
- | GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect |
61
+ | GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect (see POST vs GET below) |
62
62
  | GET | `/settings/billing/portal` | session/JWT | Customer portal redirect |
63
63
  | GET | `/api/v1/billing/status` | session/JWT | Sync + JSON billing status |
64
64
 
65
+ ## Checkout: POST vs GET
66
+
67
+ Both `/settings/billing/checkout` methods run the same handler:
68
+
69
+ | Method | Typical use | CSP note |
70
+ |--------|-------------|----------|
71
+ | **POST** | `<form method="post" action="…">` | Requires `form-action 'self'` (included in `polarCspDirectives()`) |
72
+ | **GET** | `<a href="…">Upgrade</a>` | No form submission — simpler when CSP is strict |
73
+
65
74
  ## Template usage
66
75
 
67
76
  ```njk
@@ -81,6 +90,7 @@ That's it — routes, webhooks, checkout, portal, and status API are auto-mounte
81
90
  const polar = polarPlugin({ db });
82
91
  polar.api.verifyPolarWebhook(raw, headers, secret);
83
92
  polar.api.syncPolarBillingForUser(user, knex);
93
+ polar.api.syncBillingForAppUser(user, knex, { hooks }); // dashboard page-load sync
84
94
  polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
85
95
  polar.api.handlePolarWebhookEvent(event, knex);
86
96
  polar.api.generateMigration();
@@ -118,12 +128,20 @@ polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
118
128
 
119
129
  ## Optional hooks
120
130
 
131
+ Billing columns on `users` work out of the box. If your paid tier lives elsewhere (e.g. `subscriptions.plan_tier`), use hooks:
132
+
121
133
  ```js
122
134
  polarPlugin({
123
135
  db,
136
+ syncBeforeCheckout: false, // skip Polar API sync before redirect (default: true)
124
137
  hooks: {
138
+ /** Override paid check at checkout (default: users.tier vs tierMapping) */
139
+ async isPaidUser({ user, knex }) {
140
+ const sub = await knex('subscriptions').where('user_id', user.id).first();
141
+ return sub?.plan_tier === 'pro';
142
+ },
125
143
  onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
126
- // Custom tier logic (default: updates users.* polar columns)
144
+ // Sync external tables when webhooks fire
127
145
  return knex('users').where('id', user.id).update(update);
128
146
  },
129
147
  },
@@ -133,23 +151,28 @@ polarPlugin({
133
151
  });
134
152
  ```
135
153
 
154
+ Checkout metadata sends `user_id` as a string (supports nanoid/UUID primary keys, not only integers).
155
+
136
156
  ## CSP (Helmet)
137
157
 
158
+ `polarCspDirectives()` always includes `'self'` so local login/register/checkout forms keep working. Polar domains are added for hosted checkout redirects.
159
+
138
160
  ```js
139
- const { polarCspDirectives } = require('webspresso/plugins/polar/src/urls');
161
+ const { mergePolarCspDirectives } = require('webspresso/plugins/polar/src/urls');
140
162
 
141
163
  createApp({
142
164
  helmet: {
143
165
  contentSecurityPolicy: {
144
- directives: {
145
- ...polarCspDirectives(),
146
- },
166
+ directives: mergePolarCspDirectives({
167
+ formAction: ["'self'"],
168
+ scriptSrc: ["'self'"],
169
+ }),
147
170
  },
148
171
  },
149
172
  });
150
173
  ```
151
174
 
152
- Or rely on the plugin's built-in `csp` export (auto-merged by plugin manager).
175
+ Or rely on the plugin's built-in `csp` export — the plugin manager **unions** sources per directive (does not replace your Helmet config).
153
176
 
154
177
  ## Non-goals (v1)
155
178
 
@@ -12,6 +12,7 @@ const { verifyPolarWebhook, handlePolarWebhookEvent, createWebhookRawBodyMiddlew
12
12
  const { getPolarCheckoutUrl } = require('./src/checkout');
13
13
  const { getPolarPortalUrl } = require('./src/portal');
14
14
  const { syncPolarBillingForUser } = require('./src/sync');
15
+ const { syncBillingForAppUser } = require('./src/billing-hooks');
15
16
  const {
16
17
  resolveUserFromPolarData,
17
18
  buildCheckoutMetadata,
@@ -24,6 +25,7 @@ const {
24
25
  isValidPortalRedirectUrl,
25
26
  extractCheckoutUrlFromResponse,
26
27
  polarCspDirectives,
28
+ mergePolarCspDirectives,
27
29
  } = require('./src/urls');
28
30
  const { generatePolarMigration } = require('./src/migration');
29
31
  const { polarSyncMiddleware } = require('./src/middleware');
@@ -66,6 +68,8 @@ function polarPlugin(options = {}) {
66
68
  getPolarCheckoutUrl: (params) => getPolarCheckoutUrl({ ...params, config: runtimeConfig }),
67
69
  getPolarPortalUrl: (user, baseUrl) => getPolarPortalUrl(user, baseUrl, runtimeConfig),
68
70
  syncPolarBillingForUser: (user, knex) => syncPolarBillingForUser(user, knex, runtimeConfig),
71
+ /** Dashboard/page-load sync — optional per-call hooks override */
72
+ syncBillingForAppUser: (user, knex, opts) => syncBillingForAppUser(user, knex, runtimeConfig, opts),
69
73
  handlePolarWebhookEvent: (event, knex, hooks) => handlePolarWebhookEvent(event, knex, runtimeConfig, hooks),
70
74
  resolveUserFromPolarData: (data, knex) => resolveUserFromPolarData(data, knex, runtimeConfig),
71
75
  extractCheckoutUrlFromResponse: (data) => extractCheckoutUrlFromResponse(data, runtimeConfig.sandbox),
@@ -78,6 +82,7 @@ function polarPlugin(options = {}) {
78
82
  generateMigration: (opts) => generatePolarMigration({ ...opts, fields: runtimeConfig.fields }),
79
83
  polarSyncMiddleware: (opts) => polarSyncMiddleware(runtimeConfig, opts),
80
84
  polarCspDirectives,
85
+ mergePolarCspDirectives,
81
86
  createWebhookRawBodyMiddleware,
82
87
  registerNunjucksFilters,
83
88
  dateLabel,
@@ -89,7 +94,7 @@ function polarPlugin(options = {}) {
89
94
 
90
95
  return {
91
96
  name: 'polar',
92
- version: '1.0.0',
97
+ version: '1.0.1',
93
98
  description: 'Polar.sh subscription billing — checkout, portal, webhooks, and tier sync',
94
99
 
95
100
  csp: polarCspDirectives(),
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { userHasProTier } = require('./user-resolver');
4
+ const { syncPolarBillingForUser } = require('./sync');
5
+
6
+ /**
7
+ * Invoke hooks.isPaidUser — supports ({ user, knex, config }) or legacy (user, knex, config).
8
+ * @param {Function} hook
9
+ * @param {Object} user
10
+ * @param {Object} knex
11
+ * @param {import('./config').PolarConfig} config
12
+ */
13
+ async function invokeIsPaidUserHook(hook, user, knex, config) {
14
+ const ctx = { user, knex, config };
15
+ // Legacy (user, knex, config) when declared with 2+ params; default is ({ user, knex, config })
16
+ const result = hook.length >= 2
17
+ ? await hook(user, knex, config)
18
+ : await hook(ctx);
19
+ return Boolean(result);
20
+ }
21
+
22
+ /**
23
+ * Paid-user check at checkout: hooks.isPaidUser override, else users.tier via userHasProTier.
24
+ */
25
+ async function userIsPaid(user, knex, config) {
26
+ if (typeof config.hooks?.isPaidUser === 'function') {
27
+ return invokeIsPaidUserHook(config.hooks.isPaidUser, user, knex, config);
28
+ }
29
+ return userHasProTier(user, config);
30
+ }
31
+
32
+ /**
33
+ * App-level billing sync for dashboard loaders — merges optional hook overrides per call.
34
+ * @param {Object} user
35
+ * @param {Object} knex
36
+ * @param {import('./config').PolarConfig} config
37
+ * @param {{ hooks?: Object }} [opts]
38
+ */
39
+ async function syncBillingForAppUser(user, knex, config, opts = {}) {
40
+ const merged = {
41
+ ...config,
42
+ hooks: { ...config.hooks, ...(opts.hooks || {}) },
43
+ };
44
+ return syncPolarBillingForUser(user, knex, merged);
45
+ }
46
+
47
+ module.exports = {
48
+ invokeIsPaidUserHook,
49
+ userIsPaid,
50
+ syncBillingForAppUser,
51
+ };
@@ -67,6 +67,8 @@ function resolvePolarConfig(options = {}) {
67
67
  hooks: options.hooks || {},
68
68
  requireAuth: options.requireAuth || null,
69
69
  syncMiddleware: options.syncMiddleware || null,
70
+ /** Sync from Polar API before checkout redirect (default true). Set false to skip. */
71
+ syncBeforeCheckout: options.syncBeforeCheckout !== false,
70
72
  rateLimit: options.rateLimit !== undefined ? options.rateLimit : true,
71
73
  env,
72
74
  accessToken: options.accessToken || env.POLAR_ACCESS_TOKEN || null,
@@ -1,36 +1,86 @@
1
1
  'use strict';
2
2
 
3
- const DEFAULT_RATE_LIMITS = {
4
- webhook: {
5
- limit: 120,
6
- windowMs: 60_000,
7
- message: { error: 'Too many webhook requests' },
8
- },
9
- checkout: {
10
- limit: 5,
11
- windowMs: 60_000,
12
- message: { error: 'Too many checkout attempts. Please try again later.' },
13
- keyGenerator: (req) => `polar:checkout:${req.user?.id ?? req.ip}`,
14
- },
15
- portal: {
16
- limit: 10,
17
- windowMs: 60_000,
18
- message: { error: 'Too many portal requests. Please try again later.' },
19
- keyGenerator: (req) => `polar:portal:${req.user?.id ?? req.ip}`,
20
- },
21
- status: {
22
- limit: 60,
23
- windowMs: 60_000,
24
- message: { error: 'Too many billing status requests' },
25
- keyGenerator: (req) => `polar:status:${req.user?.id ?? req.ip}`,
26
- },
27
- };
3
+ const { loadPeer } = require('../../rate-limit/index.js');
4
+
5
+ const DEFAULT_IPV6_SUBNET = 56;
6
+
7
+ /**
8
+ * @param {Function} ipKeyGenerator
9
+ * @param {string} prefix
10
+ */
11
+ function polarIpKey(ipKeyGenerator, prefix) {
12
+ return (req) => `${prefix}:${ipKeyGenerator(req.ip, DEFAULT_IPV6_SUBNET)}`;
13
+ }
14
+
15
+ /**
16
+ * Prefer authenticated user id; fall back to IPv6-safe IP key (express-rate-limit v8).
17
+ * @param {Function} ipKeyGenerator
18
+ * @param {string} prefix
19
+ */
20
+ function polarUserOrIpKey(ipKeyGenerator, prefix) {
21
+ const ipKey = polarIpKey(ipKeyGenerator, prefix);
22
+ return (req) => (req.user?.id != null ? `${prefix}:${req.user.id}` : ipKey(req));
23
+ }
24
+
25
+ /**
26
+ * @param {Function} ipKeyGenerator
27
+ */
28
+ function buildDefaultRateLimits(ipKeyGenerator) {
29
+ return {
30
+ webhook: {
31
+ limit: 120,
32
+ windowMs: 60_000,
33
+ message: { error: 'Too many webhook requests' },
34
+ keyGenerator: polarIpKey(ipKeyGenerator, 'polar:webhook'),
35
+ },
36
+ checkout: {
37
+ limit: 5,
38
+ windowMs: 60_000,
39
+ message: { error: 'Too many checkout attempts. Please try again later.' },
40
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:checkout'),
41
+ },
42
+ portal: {
43
+ limit: 10,
44
+ windowMs: 60_000,
45
+ message: { error: 'Too many portal requests. Please try again later.' },
46
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:portal'),
47
+ },
48
+ status: {
49
+ limit: 60,
50
+ windowMs: 60_000,
51
+ message: { error: 'Too many billing status requests' },
52
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:status'),
53
+ },
54
+ };
55
+ }
56
+
57
+ /** @type {ReturnType<typeof buildDefaultRateLimits>|null} */
58
+ let cachedDefaults = null;
59
+
60
+ function loadIpKeyGenerator() {
61
+ try {
62
+ const { ipKeyGenerator } = loadPeer();
63
+ return ipKeyGenerator;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function getDefaultRateLimits() {
70
+ const ipKeyGenerator = loadIpKeyGenerator();
71
+ if (!ipKeyGenerator) {
72
+ return null;
73
+ }
74
+ if (!cachedDefaults) {
75
+ cachedDefaults = buildDefaultRateLimits(ipKeyGenerator);
76
+ }
77
+ return cachedDefaults;
78
+ }
28
79
 
29
80
  /**
30
81
  * Build per-route limiter middleware from rateLimitPlugin factory.
31
82
  * @param {Object} ctx - plugin onRoutesReady context
32
83
  * @param {import('./config').PolarConfig} config
33
- * @returns {{ webhook?: Function, checkout?: Function, portal?: Function, status?: Function }}
34
84
  */
35
85
  function resolvePolarRateLimiters(ctx, config) {
36
86
  const rlOpt = config.rateLimit;
@@ -48,6 +98,14 @@ function resolvePolarRateLimiters(ctx, config) {
48
98
  return {};
49
99
  }
50
100
 
101
+ const defaults = getDefaultRateLimits();
102
+ if (!defaults) {
103
+ console.warn(
104
+ '[polar] rateLimit is enabled but express-rate-limit >= 8 is not installed — skipping polar rate limiters'
105
+ );
106
+ return {};
107
+ }
108
+
51
109
  const overrides = rlOpt === true ? {} : (rlOpt || {});
52
110
  const routes = ['webhook', 'checkout', 'portal', 'status'];
53
111
  const out = {};
@@ -55,7 +113,7 @@ function resolvePolarRateLimiters(ctx, config) {
55
113
  for (const route of routes) {
56
114
  if (overrides[route] === false) continue;
57
115
  const opts = {
58
- ...DEFAULT_RATE_LIMITS[route],
116
+ ...defaults[route],
59
117
  ...(typeof overrides[route] === 'object' ? overrides[route] : {}),
60
118
  };
61
119
  out[route] = factory(opts);
@@ -65,6 +123,16 @@ function resolvePolarRateLimiters(ctx, config) {
65
123
  }
66
124
 
67
125
  module.exports = {
68
- DEFAULT_RATE_LIMITS,
126
+ loadIpKeyGenerator,
127
+ polarIpKey,
128
+ polarUserOrIpKey,
129
+ buildDefaultRateLimits,
130
+ getDefaultRateLimits,
69
131
  resolvePolarRateLimiters,
132
+ /** @deprecated use getDefaultRateLimits() */
133
+ get DEFAULT_RATE_LIMITS() {
134
+ return getDefaultRateLimits() || buildDefaultRateLimits(
135
+ loadIpKeyGenerator() || ((ip) => String(ip))
136
+ );
137
+ },
70
138
  };
@@ -5,6 +5,7 @@ const { getPolarCheckoutUrl } = require('./checkout');
5
5
  const { getPolarPortalUrl } = require('./portal');
6
6
  const { syncPolarBillingForUser } = require('./sync');
7
7
  const { userHasProTier } = require('./user-resolver');
8
+ const { userIsPaid } = require('./billing-hooks');
8
9
 
9
10
  function resolveKnex(db) {
10
11
  return db?.knex || (typeof db?.raw === 'function' ? db : null);
@@ -81,12 +82,12 @@ function createCheckoutHandler(config) {
81
82
  if (knex) {
82
83
  const dbUser = await knex(config.userTable).where(f.id, req.user[f.id] || req.user.id).first();
83
84
  if (dbUser) user = dbUser;
84
- if (config.accessToken) {
85
+ if (config.accessToken && config.syncBeforeCheckout) {
85
86
  user = await syncPolarBillingForUser(user, knex, config);
86
87
  }
87
88
  }
88
89
 
89
- if (userHasProTier(user, config)) {
90
+ if (await userIsPaid(user, knex, config)) {
90
91
  const alreadyUrl = config.urls.alreadySubscribed.startsWith('http')
91
92
  ? config.urls.alreadySubscribed
92
93
  : config.urls.alreadySubscribed;
@@ -92,6 +92,7 @@ function extractCheckoutUrlFromResponse(data, sandbox = false) {
92
92
  }
93
93
 
94
94
  function polarCspDirectives(opts = {}) {
95
+ const self = "'self'";
95
96
  const domains = opts.domains || [
96
97
  'https://buy.polar.sh',
97
98
  'https://sandbox-buy.polar.sh',
@@ -101,8 +102,30 @@ function polarCspDirectives(opts = {}) {
101
102
  'https://sandbox-api.polar.sh',
102
103
  ];
103
104
  return {
104
- formAction: domains,
105
- connectSrc: domains,
105
+ formAction: [self, ...domains],
106
+ connectSrc: [self, ...domains],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Union Polar CSP sources into an existing Helmet/plugin CSP config (does not replace).
112
+ * @param {Object} [existing]
113
+ * @param {Object} [opts] - passed to polarCspDirectives
114
+ */
115
+ function mergePolarCspDirectives(existing = {}, opts = {}) {
116
+ const polar = polarCspDirectives(opts);
117
+ const mergeList = (key) => {
118
+ const base = existing[key];
119
+ const items = [
120
+ ...(Array.isArray(base) ? base : base ? [base] : []),
121
+ ...polar[key],
122
+ ];
123
+ return [...new Set(items)];
124
+ };
125
+ return {
126
+ ...existing,
127
+ formAction: mergeList('formAction'),
128
+ connectSrc: mergeList('connectSrc'),
106
129
  };
107
130
  }
108
131
 
@@ -114,4 +137,5 @@ module.exports = {
114
137
  isValidPortalRedirectUrl,
115
138
  extractCheckoutUrlFromResponse,
116
139
  polarCspDirectives,
140
+ mergePolarCspDirectives,
117
141
  };
@@ -113,7 +113,11 @@ async function resolveUserFromPolarData(data, knex, config) {
113
113
  const table = config.userTable;
114
114
 
115
115
  if (metadata.user_id) {
116
- const user = await knex(table).where(f.id, Number(metadata.user_id)).first();
116
+ const uid = String(metadata.user_id).trim();
117
+ let user = await knex(table).where(f.id, uid).first();
118
+ if (!user && /^\d+$/.test(uid)) {
119
+ user = await knex(table).where(f.id, Number(uid)).first();
120
+ }
117
121
  if (user) return user;
118
122
  }
119
123
 
@@ -175,4 +175,8 @@ function rateLimitPlugin(options = {}) {
175
175
  return plugin;
176
176
  }
177
177
 
178
- module.exports = { rateLimitPlugin };
178
+ module.exports = {
179
+ rateLimitPlugin,
180
+ loadPeer,
181
+ resolveLimiterConfig,
182
+ };