webspresso 0.0.91 → 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/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- 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 +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { polarApiRequest } = require('./api');
|
|
4
|
+
const {
|
|
5
|
+
userHasProTier,
|
|
6
|
+
listPolarSubscriptions,
|
|
7
|
+
pickBestActiveSubscription,
|
|
8
|
+
normalizePolarTimestamp,
|
|
9
|
+
collectExternalCustomerIds,
|
|
10
|
+
} = require('./user-resolver');
|
|
11
|
+
|
|
12
|
+
async function applyPolarProSubscription(knex, user, customerState, subscription, config) {
|
|
13
|
+
const f = config.fields;
|
|
14
|
+
const update = {
|
|
15
|
+
[f.tier]: config.proTierName,
|
|
16
|
+
[f.polarStatus]: subscription.status || 'active',
|
|
17
|
+
[f.polarCustomerId]: customerState?.id || subscription.customer_id || user[f.polarCustomerId] || null,
|
|
18
|
+
[f.polarSubscriptionId]: subscription.id || user[f.polarSubscriptionId] || null,
|
|
19
|
+
[f.polarCurrentPeriodEnd]: normalizePolarTimestamp(subscription.current_period_end),
|
|
20
|
+
[f.polarCancelAtPeriodEnd]: Boolean(subscription.cancel_at_period_end),
|
|
21
|
+
updated_at: new Date(),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (config.hooks.onSubscriptionChange) {
|
|
25
|
+
await config.hooks.onSubscriptionChange({
|
|
26
|
+
user,
|
|
27
|
+
tier: config.proTierName,
|
|
28
|
+
polarStatus: update[f.polarStatus],
|
|
29
|
+
subscription,
|
|
30
|
+
knex,
|
|
31
|
+
update,
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
await knex(config.userTable).where(f.id, user[f.id]).update(update);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return knex(config.userTable).where(f.id, user[f.id]).first();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function syncFromCustomerState(user, knex, customerState, config) {
|
|
41
|
+
if (!customerState) return user;
|
|
42
|
+
|
|
43
|
+
const activeSub = pickBestActiveSubscription(customerState.active_subscriptions, config);
|
|
44
|
+
if (activeSub) {
|
|
45
|
+
return applyPolarProSubscription(knex, user, customerState, activeSub, config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const f = config.fields;
|
|
49
|
+
if (customerState.id && customerState.id !== user[f.polarCustomerId]) {
|
|
50
|
+
const patch = {
|
|
51
|
+
[f.polarCustomerId]: customerState.id,
|
|
52
|
+
updated_at: new Date(),
|
|
53
|
+
};
|
|
54
|
+
await knex(config.userTable).where(f.id, user[f.id]).update(patch);
|
|
55
|
+
return { ...user, [f.polarCustomerId]: customerState.id };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return user;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function syncPolarBillingForUser(user, knex, config) {
|
|
62
|
+
if (!user || !knex || !config.accessToken) {
|
|
63
|
+
return user;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const apiOpts = { accessToken: config.accessToken, sandbox: config.sandbox };
|
|
67
|
+
const externalIds = collectExternalCustomerIds(user, config);
|
|
68
|
+
|
|
69
|
+
for (const externalId of externalIds) {
|
|
70
|
+
try {
|
|
71
|
+
const state = await polarApiRequest(
|
|
72
|
+
`/customers/external/${encodeURIComponent(externalId)}/state`,
|
|
73
|
+
apiOpts
|
|
74
|
+
);
|
|
75
|
+
user = await syncFromCustomerState(user, knex, state, config);
|
|
76
|
+
if (userHasProTier(user, config)) return user;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (!String(err.message || '').includes('404')) {
|
|
79
|
+
console.error('[polar] Sync error (external state):', externalId, err.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const f = config.fields;
|
|
85
|
+
if (user[f.polarCustomerId]) {
|
|
86
|
+
try {
|
|
87
|
+
const state = await polarApiRequest(`/customers/${user[f.polarCustomerId]}/state`, apiOpts);
|
|
88
|
+
user = await syncFromCustomerState(user, knex, state, config);
|
|
89
|
+
if (userHasProTier(user, config)) return user;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (!String(err.message || '').includes('404')) {
|
|
92
|
+
console.error('[polar] Sync error (customer state):', user[f.polarCustomerId], err.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (user[f.email]) {
|
|
98
|
+
try {
|
|
99
|
+
const customersData = await polarApiRequest(
|
|
100
|
+
`/customers/?email=${encodeURIComponent(String(user[f.email]).trim())}&limit=5`,
|
|
101
|
+
apiOpts
|
|
102
|
+
);
|
|
103
|
+
for (const customer of listPolarSubscriptions(customersData)) {
|
|
104
|
+
if (!customer?.id) continue;
|
|
105
|
+
const state = await polarApiRequest(`/customers/${customer.id}/state`, apiOpts);
|
|
106
|
+
user = await syncFromCustomerState(user, knex, state, config);
|
|
107
|
+
if (userHasProTier(user, config)) return user;
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error('[polar] Sync error (email lookup):', user[f.email], err.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const externalId of externalIds) {
|
|
115
|
+
try {
|
|
116
|
+
const subsData = await polarApiRequest(
|
|
117
|
+
`/subscriptions/?external_customer_id=${encodeURIComponent(externalId)}&limit=10`,
|
|
118
|
+
apiOpts
|
|
119
|
+
);
|
|
120
|
+
const activeSub = pickBestActiveSubscription(listPolarSubscriptions(subsData), config);
|
|
121
|
+
if (activeSub) {
|
|
122
|
+
return applyPolarProSubscription(knex, user, { id: activeSub.customer_id }, activeSub, config);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (!String(err.message || '').includes('404')) {
|
|
126
|
+
console.error('[polar] Sync error (subscriptions list):', externalId, err.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return user;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
applyPolarProSubscription,
|
|
136
|
+
syncFromCustomerState,
|
|
137
|
+
syncPolarBillingForUser,
|
|
138
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function getPolarBuyBase(sandbox) {
|
|
4
|
+
return sandbox ? 'https://sandbox-buy.polar.sh' : 'https://buy.polar.sh';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getPolarWebBase(sandbox) {
|
|
8
|
+
return sandbox ? 'https://sandbox.polar.sh' : 'https://polar.sh';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isBlockedPolarUrl(urlString) {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(urlString);
|
|
14
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
15
|
+
return (
|
|
16
|
+
path === '/404'
|
|
17
|
+
|| path === '/purchases/subscriptions'
|
|
18
|
+
|| (url.hostname === 'polar.sh' && path === '/')
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isValidCheckoutRedirectUrl(urlString) {
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(urlString);
|
|
28
|
+
if (isBlockedPolarUrl(urlString)) return false;
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
url.hostname === 'buy.polar.sh'
|
|
32
|
+
|| url.hostname === 'sandbox-buy.polar.sh'
|
|
33
|
+
) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const webHosts = new Set(['polar.sh', 'sandbox.polar.sh', 'www.polar.sh']);
|
|
38
|
+
if (webHosts.has(url.hostname)) {
|
|
39
|
+
return (
|
|
40
|
+
url.pathname.startsWith('/checkout/')
|
|
41
|
+
|| url.pathname.includes('/polar_c_')
|
|
42
|
+
|| url.pathname.includes('/polar_cl_')
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isValidPortalRedirectUrl(urlString) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(urlString);
|
|
55
|
+
if (isBlockedPolarUrl(urlString)) return false;
|
|
56
|
+
if (url.hostname === 'polar.sh' || url.hostname === 'sandbox.polar.sh') {
|
|
57
|
+
return /^\/[^/]+\/portal(?:\/|$)/.test(url.pathname);
|
|
58
|
+
}
|
|
59
|
+
return url.hostname.endsWith('.polar.sh') && url.pathname.includes('/portal');
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractCheckoutUrlFromResponse(data, sandbox = false) {
|
|
66
|
+
if (!data || typeof data !== 'object') return null;
|
|
67
|
+
|
|
68
|
+
const webBase = getPolarWebBase(sandbox);
|
|
69
|
+
const candidates = [
|
|
70
|
+
data.url,
|
|
71
|
+
data.checkout_url,
|
|
72
|
+
data.checkout?.url,
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
|
|
75
|
+
for (const candidate of candidates) {
|
|
76
|
+
const normalized = candidate.startsWith('/')
|
|
77
|
+
? `${webBase}${candidate}`
|
|
78
|
+
: candidate;
|
|
79
|
+
if (isValidCheckoutRedirectUrl(normalized)) {
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (data.client_secret) {
|
|
85
|
+
const built = `${webBase}/checkout/${data.client_secret}`;
|
|
86
|
+
if (isValidCheckoutRedirectUrl(built)) {
|
|
87
|
+
return built;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function polarCspDirectives(opts = {}) {
|
|
95
|
+
const domains = opts.domains || [
|
|
96
|
+
'https://buy.polar.sh',
|
|
97
|
+
'https://sandbox-buy.polar.sh',
|
|
98
|
+
'https://polar.sh',
|
|
99
|
+
'https://sandbox.polar.sh',
|
|
100
|
+
'https://api.polar.sh',
|
|
101
|
+
'https://sandbox-api.polar.sh',
|
|
102
|
+
];
|
|
103
|
+
return {
|
|
104
|
+
formAction: domains,
|
|
105
|
+
connectSrc: domains,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
getPolarBuyBase,
|
|
111
|
+
getPolarWebBase,
|
|
112
|
+
isBlockedPolarUrl,
|
|
113
|
+
isValidCheckoutRedirectUrl,
|
|
114
|
+
isValidPortalRedirectUrl,
|
|
115
|
+
extractCheckoutUrlFromResponse,
|
|
116
|
+
polarCspDirectives,
|
|
117
|
+
};
|
|
@@ -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
|
+
};
|