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.
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/tokens.js +16 -0
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/index.js +4 -1
- package/core/orm/transaction.js +3 -1
- package/core/ssr/stream.js +58 -14
- package/package.json +1 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/import.js +76 -39
- package/plugins/index.js +2 -0
- package/plugins/mcp/transports/sse.js +23 -6
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +19 -2
- package/src/helpers.js +60 -13
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PRO_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing']);
|
|
4
|
+
|
|
5
|
+
function normalizePolarEventData(data) {
|
|
6
|
+
if (!data || typeof data !== 'object') return {};
|
|
7
|
+
|
|
8
|
+
const subscription = data.subscription && typeof data.subscription === 'object'
|
|
9
|
+
? data.subscription
|
|
10
|
+
: null;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
...data,
|
|
14
|
+
...(subscription || {}),
|
|
15
|
+
metadata: data.metadata || subscription?.metadata || data.checkout?.metadata || {},
|
|
16
|
+
customer: data.customer || subscription?.customer || data.checkout?.customer || null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectPolarMetadata(data) {
|
|
21
|
+
const normalized = normalizePolarEventData(data);
|
|
22
|
+
const metadata = normalized.metadata && typeof normalized.metadata === 'object'
|
|
23
|
+
? normalized.metadata
|
|
24
|
+
: {};
|
|
25
|
+
return { normalized, metadata };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isProSubscriptionStatus(status) {
|
|
29
|
+
return PRO_SUBSCRIPTION_STATUSES.has(String(status || '').toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function userHasProTier(user, config) {
|
|
33
|
+
if (!user) return false;
|
|
34
|
+
const tierField = config.fields.tier;
|
|
35
|
+
const tier = user[tierField];
|
|
36
|
+
return config.proTiers.includes(tier);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePolarTimestamp(value) {
|
|
40
|
+
if (!value) return null;
|
|
41
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
42
|
+
const parsed = new Date(value);
|
|
43
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizePolarTimestamp(value) {
|
|
47
|
+
const parsed = parsePolarTimestamp(value);
|
|
48
|
+
return parsed ? parsed.toISOString() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function listPolarSubscriptions(data) {
|
|
52
|
+
if (!data) return [];
|
|
53
|
+
if (Array.isArray(data)) return data;
|
|
54
|
+
if (Array.isArray(data.items)) return data.items;
|
|
55
|
+
if (Array.isArray(data.results)) return data.results;
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pickBestActiveSubscription(subscriptions, config) {
|
|
60
|
+
const list = Array.isArray(subscriptions) ? subscriptions : [];
|
|
61
|
+
if (!list.length) return null;
|
|
62
|
+
|
|
63
|
+
const activeOnly = list.filter((sub) => !sub.status || isProSubscriptionStatus(sub.status));
|
|
64
|
+
if (!activeOnly.length) return null;
|
|
65
|
+
|
|
66
|
+
const productIds = new Set();
|
|
67
|
+
for (const planKey of config.proTiers.flatMap((t) => config.tierMapping[t] || [])) {
|
|
68
|
+
const pid = config.plans[planKey];
|
|
69
|
+
if (pid) productIds.add(pid);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (productIds.size) {
|
|
73
|
+
const matched = activeOnly.find((sub) => productIds.has(sub.product_id));
|
|
74
|
+
if (matched) return matched;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return activeOnly[0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildCheckoutMetadata(user, config, customFields = {}) {
|
|
81
|
+
const f = config.fields;
|
|
82
|
+
const metadata = {};
|
|
83
|
+
|
|
84
|
+
if (user[f.id] != null) metadata.user_id = String(user[f.id]);
|
|
85
|
+
if (user[f.publicId]) metadata.public_id = String(user[f.publicId]);
|
|
86
|
+
if (user.username) metadata.username = String(user.username);
|
|
87
|
+
|
|
88
|
+
for (const [key, value] of Object.entries(customFields)) {
|
|
89
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
|
90
|
+
metadata[key] = String(value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cleaned = {};
|
|
95
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
96
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
|
97
|
+
cleaned[key] = String(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return cleaned;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {Object} data
|
|
105
|
+
* @param {Object} knex
|
|
106
|
+
* @param {import('./config').PolarConfig} config
|
|
107
|
+
*/
|
|
108
|
+
async function resolveUserFromPolarData(data, knex, config) {
|
|
109
|
+
if (!data || !knex) return null;
|
|
110
|
+
|
|
111
|
+
const { normalized, metadata } = collectPolarMetadata(data);
|
|
112
|
+
const f = config.fields;
|
|
113
|
+
const table = config.userTable;
|
|
114
|
+
|
|
115
|
+
if (metadata.user_id) {
|
|
116
|
+
const user = await knex(table).where(f.id, Number(metadata.user_id)).first();
|
|
117
|
+
if (user) return user;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (metadata.public_id) {
|
|
121
|
+
const user = await knex(table).where(f.publicId, String(metadata.public_id)).first();
|
|
122
|
+
if (user) return user;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const externalId =
|
|
126
|
+
normalized.external_customer_id
|
|
127
|
+
|| normalized.external_id
|
|
128
|
+
|| data.external_id
|
|
129
|
+
|| normalized.customer?.external_id
|
|
130
|
+
|| data.customer?.external_id
|
|
131
|
+
|| null;
|
|
132
|
+
|
|
133
|
+
if (externalId) {
|
|
134
|
+
let user = await knex(table).where(f.publicId, String(externalId)).first();
|
|
135
|
+
if (!user && /^\d+$/.test(String(externalId))) {
|
|
136
|
+
user = await knex(table).where(f.id, Number(externalId)).first();
|
|
137
|
+
}
|
|
138
|
+
if (user) return user;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const email = (
|
|
142
|
+
normalized.customer?.email
|
|
143
|
+
|| normalized.customer_email
|
|
144
|
+
|| normalized.email
|
|
145
|
+
|| data.customer?.email
|
|
146
|
+
);
|
|
147
|
+
if (email) {
|
|
148
|
+
const user = await knex(table).whereRaw('LOWER(??) = ?', [f.email, String(email).toLowerCase().trim()]).first();
|
|
149
|
+
if (user) return user;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const customerId = normalized.customer_id || normalized.customer?.id || data.customer_id || null;
|
|
153
|
+
if (customerId) {
|
|
154
|
+
const user = await knex(table).where(f.polarCustomerId, customerId).first();
|
|
155
|
+
if (user) return user;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractSubscriptionFields(data, user, config) {
|
|
162
|
+
const normalized = normalizePolarEventData(data);
|
|
163
|
+
const f = config.fields;
|
|
164
|
+
return {
|
|
165
|
+
subscriptionId: normalized.id || user?.[f.polarSubscriptionId] || null,
|
|
166
|
+
customerId: normalized.customer_id || normalized.customer?.id || user?.[f.polarCustomerId] || null,
|
|
167
|
+
status: normalized.status || 'active',
|
|
168
|
+
periodEnd: normalizePolarTimestamp(normalized.current_period_end),
|
|
169
|
+
cancelAtPeriodEnd: Boolean(normalized.cancel_at_period_end),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function collectExternalCustomerIds(user, config) {
|
|
174
|
+
const f = config.fields;
|
|
175
|
+
return [...new Set([
|
|
176
|
+
user?.[f.publicId] && String(user[f.publicId]),
|
|
177
|
+
user?.[f.id] != null && String(user[f.id]),
|
|
178
|
+
].filter(Boolean))];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
PRO_SUBSCRIPTION_STATUSES,
|
|
183
|
+
normalizePolarEventData,
|
|
184
|
+
collectPolarMetadata,
|
|
185
|
+
isProSubscriptionStatus,
|
|
186
|
+
userHasProTier,
|
|
187
|
+
parsePolarTimestamp,
|
|
188
|
+
normalizePolarTimestamp,
|
|
189
|
+
listPolarSubscriptions,
|
|
190
|
+
pickBestActiveSubscription,
|
|
191
|
+
buildCheckoutMetadata,
|
|
192
|
+
resolveUserFromPolarData,
|
|
193
|
+
extractSubscriptionFields,
|
|
194
|
+
collectExternalCustomerIds,
|
|
195
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { syncPolarBillingForUser, applyPolarProSubscription } = require('./sync');
|
|
5
|
+
const {
|
|
6
|
+
collectPolarMetadata,
|
|
7
|
+
isProSubscriptionStatus,
|
|
8
|
+
userHasProTier,
|
|
9
|
+
parsePolarTimestamp,
|
|
10
|
+
pickBestActiveSubscription,
|
|
11
|
+
resolveUserFromPolarData,
|
|
12
|
+
extractSubscriptionFields,
|
|
13
|
+
} = require('./user-resolver');
|
|
14
|
+
|
|
15
|
+
function verifyPolarWebhook(rawPayload, headers, secret) {
|
|
16
|
+
if (!secret) return false;
|
|
17
|
+
|
|
18
|
+
const signatureHeader =
|
|
19
|
+
headers['webhook-signature']
|
|
20
|
+
|| headers['polar-webhook-signature']
|
|
21
|
+
|| headers['x-polar-signature'];
|
|
22
|
+
|
|
23
|
+
if (!signatureHeader) return false;
|
|
24
|
+
|
|
25
|
+
const webhookId = headers['webhook-id'] || headers['x-webhook-id'] || '';
|
|
26
|
+
const webhookTimestamp = headers['webhook-timestamp'] || headers['x-webhook-timestamp'] || '';
|
|
27
|
+
|
|
28
|
+
const candidates = signatureHeader
|
|
29
|
+
.split(/[\s,]+/)
|
|
30
|
+
.map((s) => s.trim().replace(/^v1=|^v1,/, ''))
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
|
|
33
|
+
const payloadStr = typeof rawPayload === 'string' ? rawPayload : rawPayload.toString('utf8');
|
|
34
|
+
const standardToSign = webhookId && webhookTimestamp ? `${webhookId}.${webhookTimestamp}.${payloadStr}` : payloadStr;
|
|
35
|
+
|
|
36
|
+
const cleanSecret = secret.startsWith('whsec_')
|
|
37
|
+
? Buffer.from(secret.slice(6), 'base64')
|
|
38
|
+
: secret;
|
|
39
|
+
|
|
40
|
+
const expectedBase64 = crypto.createHmac('sha256', cleanSecret).update(standardToSign).digest('base64');
|
|
41
|
+
const expectedHex = crypto.createHmac('sha256', cleanSecret).update(standardToSign).digest('hex');
|
|
42
|
+
const rawExpectedBase64 = crypto.createHmac('sha256', cleanSecret).update(payloadStr).digest('base64');
|
|
43
|
+
const rawExpectedHex = crypto.createHmac('sha256', cleanSecret).update(payloadStr).digest('hex');
|
|
44
|
+
|
|
45
|
+
const expectedList = [expectedBase64, expectedHex, rawExpectedBase64, rawExpectedHex];
|
|
46
|
+
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
for (const exp of expectedList) {
|
|
49
|
+
try {
|
|
50
|
+
const a = Buffer.from(candidate);
|
|
51
|
+
const b = Buffer.from(exp);
|
|
52
|
+
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// continue
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function updateUserTier(knex, user, patch, config) {
|
|
65
|
+
const f = config.fields;
|
|
66
|
+
if (config.hooks.onSubscriptionChange && patch.tier !== undefined) {
|
|
67
|
+
await config.hooks.onSubscriptionChange({
|
|
68
|
+
user,
|
|
69
|
+
tier: patch.tier,
|
|
70
|
+
polarStatus: patch[f.polarStatus],
|
|
71
|
+
subscription: patch._subscription || null,
|
|
72
|
+
knex,
|
|
73
|
+
update: patch,
|
|
74
|
+
});
|
|
75
|
+
return knex(config.userTable).where(f.id, user[f.id]).first();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { _subscription, ...dbPatch } = patch;
|
|
79
|
+
await knex(config.userTable).where(f.id, user[f.id]).update({
|
|
80
|
+
...dbPatch,
|
|
81
|
+
updated_at: new Date(),
|
|
82
|
+
});
|
|
83
|
+
return knex(config.userTable).where(f.id, user[f.id]).first();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handlePolarWebhookEvent(event, knex, config, hooks = {}) {
|
|
87
|
+
const mergedConfig = { ...config, hooks: { ...config.hooks, ...hooks } };
|
|
88
|
+
|
|
89
|
+
if (!event || !event.type || !knex) {
|
|
90
|
+
return { processed: false, action: 'invalid_event' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { type, data } = event;
|
|
94
|
+
const user = await resolveUserFromPolarData(data, knex, mergedConfig);
|
|
95
|
+
|
|
96
|
+
if (!user) {
|
|
97
|
+
console.warn('[polar] Webhook user_not_found', { type, metadata: collectPolarMetadata(data).metadata });
|
|
98
|
+
return { processed: false, action: 'user_not_found', eventType: type };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const f = mergedConfig.fields;
|
|
102
|
+
const {
|
|
103
|
+
subscriptionId,
|
|
104
|
+
customerId,
|
|
105
|
+
status,
|
|
106
|
+
periodEnd,
|
|
107
|
+
cancelAtPeriodEnd,
|
|
108
|
+
} = extractSubscriptionFields(data, user, mergedConfig);
|
|
109
|
+
|
|
110
|
+
switch (type) {
|
|
111
|
+
case 'subscription.created':
|
|
112
|
+
case 'subscription.updated':
|
|
113
|
+
case 'subscription.active':
|
|
114
|
+
case 'subscription.uncanceled': {
|
|
115
|
+
const isProActive = isProSubscriptionStatus(status);
|
|
116
|
+
const patch = {
|
|
117
|
+
[f.polarStatus]: status,
|
|
118
|
+
[f.polarCustomerId]: customerId,
|
|
119
|
+
[f.polarSubscriptionId]: subscriptionId,
|
|
120
|
+
[f.polarCurrentPeriodEnd]: periodEnd,
|
|
121
|
+
[f.polarCancelAtPeriodEnd]: cancelAtPeriodEnd,
|
|
122
|
+
_subscription: data,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (isProActive) {
|
|
126
|
+
patch[f.tier] = mergedConfig.proTierName;
|
|
127
|
+
patch.tier = mergedConfig.proTierName;
|
|
128
|
+
await updateUserTier(knex, user, patch, mergedConfig);
|
|
129
|
+
return { processed: true, action: 'subscription_activated', userId: user[f.id], tier: mergedConfig.proTierName };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
patch[f.tier] = user[f.tier];
|
|
133
|
+
patch.tier = user[f.tier];
|
|
134
|
+
await updateUserTier(knex, user, patch, mergedConfig);
|
|
135
|
+
|
|
136
|
+
const syncedUser = await syncPolarBillingForUser(user, knex, mergedConfig);
|
|
137
|
+
return {
|
|
138
|
+
processed: true,
|
|
139
|
+
action: userHasProTier(syncedUser, mergedConfig) ? 'subscription_synced_to_pro' : 'subscription_pending',
|
|
140
|
+
userId: user[f.id],
|
|
141
|
+
tier: syncedUser?.[f.tier] || user[f.tier],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'subscription.canceled':
|
|
146
|
+
case 'subscription.revoked': {
|
|
147
|
+
const periodEndDate = parsePolarTimestamp(periodEnd);
|
|
148
|
+
const isImmediate = type === 'subscription.revoked' || !periodEndDate || periodEndDate <= new Date();
|
|
149
|
+
|
|
150
|
+
const patch = {
|
|
151
|
+
[f.polarStatus]: 'canceled',
|
|
152
|
+
[f.polarCancelAtPeriodEnd]: true,
|
|
153
|
+
_subscription: data,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (isImmediate) {
|
|
157
|
+
patch[f.tier] = mergedConfig.freeTierName;
|
|
158
|
+
patch.tier = mergedConfig.freeTierName;
|
|
159
|
+
} else {
|
|
160
|
+
patch[f.tier] = user[f.tier];
|
|
161
|
+
patch.tier = user[f.tier];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await updateUserTier(knex, user, patch, mergedConfig);
|
|
165
|
+
return {
|
|
166
|
+
processed: true,
|
|
167
|
+
action: isImmediate ? 'downgraded_to_free' : 'marked_cancel_pending',
|
|
168
|
+
userId: user[f.id],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'order.created':
|
|
173
|
+
case 'order.updated':
|
|
174
|
+
case 'order.paid':
|
|
175
|
+
case 'checkout.updated': {
|
|
176
|
+
const syncedUser = await syncPolarBillingForUser(user, knex, mergedConfig);
|
|
177
|
+
if (userHasProTier(syncedUser, mergedConfig)) {
|
|
178
|
+
return { processed: true, action: 'order_completed', userId: user[f.id], tier: mergedConfig.proTierName };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await knex(mergedConfig.userTable).where(f.id, user[f.id]).update({
|
|
182
|
+
[f.polarCustomerId]: customerId || user[f.polarCustomerId],
|
|
183
|
+
updated_at: new Date(),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return { processed: true, action: 'checkout_recorded', userId: user[f.id], tier: user[f.tier] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'customer.state_changed': {
|
|
190
|
+
const activeSub = pickBestActiveSubscription(data.active_subscriptions, mergedConfig);
|
|
191
|
+
if (activeSub) {
|
|
192
|
+
await applyPolarProSubscription(knex, user, data, activeSub, mergedConfig);
|
|
193
|
+
return { processed: true, action: 'customer_state_pro', userId: user[f.id], tier: mergedConfig.proTierName };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const hasNoActiveSubs = Array.isArray(data.active_subscriptions) && data.active_subscriptions.length === 0;
|
|
197
|
+
if (hasNoActiveSubs && userHasProTier(user, mergedConfig)) {
|
|
198
|
+
const patch = {
|
|
199
|
+
[f.tier]: mergedConfig.freeTierName,
|
|
200
|
+
tier: mergedConfig.freeTierName,
|
|
201
|
+
[f.polarStatus]: 'none',
|
|
202
|
+
[f.polarCancelAtPeriodEnd]: false,
|
|
203
|
+
[f.polarCurrentPeriodEnd]: null,
|
|
204
|
+
[f.polarSubscriptionId]: null,
|
|
205
|
+
[f.polarCustomerId]: data.id || user[f.polarCustomerId],
|
|
206
|
+
};
|
|
207
|
+
await updateUserTier(knex, user, patch, mergedConfig);
|
|
208
|
+
return { processed: true, action: 'customer_state_free', userId: user[f.id], tier: mergedConfig.freeTierName };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (data.id) {
|
|
212
|
+
await knex(mergedConfig.userTable).where(f.id, user[f.id]).update({
|
|
213
|
+
[f.polarCustomerId]: data.id,
|
|
214
|
+
updated_at: new Date(),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { processed: true, action: 'customer_state_recorded', userId: user[f.id], tier: user[f.tier] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
default:
|
|
222
|
+
return { processed: true, action: 'ignored_event_type', type };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function createWebhookRawBodyMiddleware(paths = []) {
|
|
227
|
+
const normalized = new Set(
|
|
228
|
+
(Array.isArray(paths) ? paths : [paths]).map((p) => (p.startsWith('/') ? p : `/${p}`))
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return function polarWebhookRawBody(req, res, next) {
|
|
232
|
+
if (!normalized.has(req.path)) return next();
|
|
233
|
+
|
|
234
|
+
const chunks = [];
|
|
235
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
236
|
+
req.on('end', () => {
|
|
237
|
+
req.rawBody = Buffer.concat(chunks);
|
|
238
|
+
next();
|
|
239
|
+
});
|
|
240
|
+
req.on('error', next);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
verifyPolarWebhook,
|
|
246
|
+
handlePolarWebhookEvent,
|
|
247
|
+
createWebhookRawBodyMiddleware,
|
|
248
|
+
};
|
package/src/file-router.js
CHANGED
|
@@ -113,8 +113,9 @@ function rewriteDynamicRouteMarkers(route) {
|
|
|
113
113
|
}
|
|
114
114
|
const inner = route.slice(open + 1, close);
|
|
115
115
|
let repl;
|
|
116
|
-
if (inner.startsWith('...')
|
|
117
|
-
|
|
116
|
+
if (inner.startsWith('...')) {
|
|
117
|
+
const paramName = inner.length > 3 ? inner.slice(3) : 'splat';
|
|
118
|
+
repl = '*' + paramName;
|
|
118
119
|
} else if (inner.length > 0 && inner.indexOf('[') === -1) {
|
|
119
120
|
repl = ':' + inner;
|
|
120
121
|
} else {
|
|
@@ -1049,6 +1050,14 @@ function mountPages(app, options) {
|
|
|
1049
1050
|
|
|
1050
1051
|
app[route.method](route.routePath, async (req, res, next) => {
|
|
1051
1052
|
try {
|
|
1053
|
+
if (route.routePath.includes('*') && req.params) {
|
|
1054
|
+
for (const val of Object.values(req.params)) {
|
|
1055
|
+
if (Array.isArray(val) && req.params[0] === undefined) {
|
|
1056
|
+
req.params[0] = val.join('/');
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1052
1061
|
// Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
|
|
1053
1062
|
if (db != null) {
|
|
1054
1063
|
req.db = db;
|
|
@@ -1133,6 +1142,14 @@ function mountPages(app, options) {
|
|
|
1133
1142
|
|
|
1134
1143
|
app.get(route.routePath, async (req, res, next) => {
|
|
1135
1144
|
try {
|
|
1145
|
+
if (route.routePath.includes('*') && req.params) {
|
|
1146
|
+
for (const val of Object.values(req.params)) {
|
|
1147
|
+
if (Array.isArray(val) && req.params[0] === undefined) {
|
|
1148
|
+
req.params[0] = val.join('/');
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1136
1153
|
// Detect locale
|
|
1137
1154
|
const locale = detectLocale(req);
|
|
1138
1155
|
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
package/src/helpers.js
CHANGED
|
@@ -616,31 +616,54 @@ function createHelpers(ctx) {
|
|
|
616
616
|
// ============================================
|
|
617
617
|
|
|
618
618
|
/**
|
|
619
|
-
* Get
|
|
619
|
+
* Get CSP nonce if available from request context
|
|
620
|
+
* @returns {string} Nonce string or empty string
|
|
621
|
+
*/
|
|
622
|
+
nonce() {
|
|
623
|
+
return (
|
|
624
|
+
req?.res?.locals?.cspNonce ||
|
|
625
|
+
req?.res?.locals?.nonce ||
|
|
626
|
+
res?.locals?.cspNonce ||
|
|
627
|
+
res?.locals?.nonce ||
|
|
628
|
+
req?.cspNonce ||
|
|
629
|
+
req?.nonce ||
|
|
630
|
+
''
|
|
631
|
+
);
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Get injected head content (CSS, scripts)
|
|
636
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
620
637
|
* @returns {string} HTML content for head section
|
|
621
638
|
*/
|
|
622
|
-
injectHead() {
|
|
639
|
+
injectHead(options) {
|
|
640
|
+
const explicitNonce = typeof options === 'string' ? options : options?.nonce;
|
|
641
|
+
const nonce = explicitNonce || this.nonce();
|
|
623
642
|
const injector = getScriptInjector();
|
|
624
643
|
let content = '';
|
|
625
644
|
|
|
626
645
|
// Add styles
|
|
627
|
-
const styles = injector.getStylesContent();
|
|
646
|
+
const styles = injector.getStylesContent(nonce ? { nonce } : options);
|
|
628
647
|
if (styles) {
|
|
629
|
-
|
|
648
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
|
|
649
|
+
content += `<style id="webspresso-injected-styles"${nonceAttr}>\n${styles}\n</style>\n`;
|
|
630
650
|
}
|
|
631
651
|
|
|
632
652
|
// Add head scripts
|
|
633
|
-
content += injector.getHeadContent();
|
|
653
|
+
content += injector.getHeadContent(nonce ? { nonce } : options);
|
|
634
654
|
|
|
635
655
|
return content;
|
|
636
656
|
},
|
|
637
657
|
|
|
638
658
|
/**
|
|
639
659
|
* Get injected body content (for end of body)
|
|
660
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
640
661
|
* @returns {string} HTML content for body end
|
|
641
662
|
*/
|
|
642
|
-
injectBody() {
|
|
643
|
-
|
|
663
|
+
injectBody(options) {
|
|
664
|
+
const explicitNonce = typeof options === 'string' ? options : options?.nonce;
|
|
665
|
+
const nonce = explicitNonce || this.nonce();
|
|
666
|
+
return getScriptInjector().getBodyContent(nonce ? { nonce } : options);
|
|
644
667
|
},
|
|
645
668
|
|
|
646
669
|
/**
|
|
@@ -726,25 +749,49 @@ class ScriptInjector {
|
|
|
726
749
|
|
|
727
750
|
/**
|
|
728
751
|
* Get head content sorted by priority
|
|
752
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
729
753
|
*/
|
|
730
|
-
getHeadContent() {
|
|
754
|
+
getHeadContent(options = {}) {
|
|
755
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
731
756
|
const sorted = [...this.headScripts].sort((a, b) => b.priority - a.priority);
|
|
732
|
-
return sorted.map(s =>
|
|
757
|
+
return sorted.map(s => {
|
|
758
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
759
|
+
if (nonce && typeof content === 'string') {
|
|
760
|
+
content = content.replace(/<script(?![^>]*\bnonce=)/gi, `<script nonce="${nonce}"`);
|
|
761
|
+
}
|
|
762
|
+
return content;
|
|
763
|
+
}).join('\n');
|
|
733
764
|
}
|
|
734
765
|
|
|
735
766
|
/**
|
|
736
767
|
* Get body content sorted by priority
|
|
768
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
737
769
|
*/
|
|
738
|
-
getBodyContent() {
|
|
770
|
+
getBodyContent(options = {}) {
|
|
771
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
739
772
|
const sorted = [...this.bodyScripts].sort((a, b) => b.priority - a.priority);
|
|
740
|
-
return sorted.map(s =>
|
|
773
|
+
return sorted.map(s => {
|
|
774
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
775
|
+
if (nonce && typeof content === 'string') {
|
|
776
|
+
content = content.replace(/<script(?![^>]*\bnonce=)/gi, `<script nonce="${nonce}"`);
|
|
777
|
+
}
|
|
778
|
+
return content;
|
|
779
|
+
}).join('\n');
|
|
741
780
|
}
|
|
742
781
|
|
|
743
782
|
/**
|
|
744
783
|
* Get styles content
|
|
784
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
745
785
|
*/
|
|
746
|
-
getStylesContent() {
|
|
747
|
-
|
|
786
|
+
getStylesContent(options = {}) {
|
|
787
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
788
|
+
return this.styles.map(s => {
|
|
789
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
790
|
+
if (nonce && typeof content === 'string') {
|
|
791
|
+
content = content.replace(/<style(?![^>]*\bnonce=)/gi, `<style nonce="${nonce}"`);
|
|
792
|
+
}
|
|
793
|
+
return content;
|
|
794
|
+
}).join('\n');
|
|
748
795
|
}
|
|
749
796
|
|
|
750
797
|
/**
|
|
@@ -213,6 +213,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
213
213
|
| `auditLogPlugin` | Admin mutation audit trail |
|
|
214
214
|
| `recaptchaPlugin` | v2/v3 + middleware |
|
|
215
215
|
| `emailPlugin` | MJML + Nodemailer sending with optional admin UI and auth bridge |
|
|
216
|
+
| `polarPlugin` | Polar.sh billing: checkout, portal, webhooks, tier sync (`webspresso polar:migrate`) |
|
|
216
217
|
| `seoCheckerPlugin` | Dev SEO panel |
|
|
217
218
|
| `restResourcePlugin` | Opt-in REST CRUD from models; `?include=` uses ORM eager load (single-level relations only) |
|
|
218
219
|
| `ormCacheAdminPlugin` | Admin page for ORM cache metrics / purge / invalidate (`db.cache` required) |
|