webspresso 0.0.93 ā 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/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 +15 -4
- 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 +56 -0
- package/plugins/polar/README.md +181 -0
- package/plugins/polar/index.js +149 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +90 -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 +138 -0
- package/plugins/polar/src/routes.js +213 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +141 -0
- package/plugins/polar/src/user-resolver.js +199 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/plugins/rate-limit/index.js +5 -1
- 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,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();
|
package/core/auth/tokens.js
CHANGED
|
@@ -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
|
};
|
package/core/orm/cache/layer.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/core/orm/transaction.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/core/ssr/stream.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
102
|
+
if (!stream.destroyed) {
|
|
103
|
+
stream.push(errorChunk);
|
|
104
|
+
}
|
|
93
105
|
}
|
|
94
106
|
});
|
|
95
107
|
|
|
96
108
|
await Promise.all(resolutionPromises);
|
|
97
|
-
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.
|
|
187
|
+
if (!finished && !res.writableEnded) {
|
|
188
|
+
res.write(chunk);
|
|
189
|
+
}
|
|
152
190
|
});
|
|
153
191
|
|
|
154
192
|
stream.on('end', () => {
|
|
155
|
-
|
|
156
|
-
|
|
193
|
+
if (!finished) {
|
|
194
|
+
finished = true;
|
|
195
|
+
res.end();
|
|
196
|
+
resolve();
|
|
197
|
+
}
|
|
157
198
|
});
|
|
158
199
|
|
|
159
200
|
stream.on('error', (err) => {
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
res.
|
|
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.
|
|
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
|
|
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
|
|
20
|
-
"./
|
|
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"
|
|
@@ -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
|
|
package/plugins/content/index.js
CHANGED
|
@@ -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
|
-
|
|
65
|
+
`<script${nonceAttr}>window.__WS_CONTENT__=` +
|
|
64
66
|
JSON.stringify({ enabled: true, adminPath }) +
|
|
65
67
|
';</script>';
|
|
66
|
-
const styleTag =
|
|
67
|
-
const scriptTag =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
if (mode === 'insert') {
|
|
285
|
+
await targetRepo.create(payload);
|
|
286
|
+
summary.created++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
277
289
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 ||
|
|
285
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
84
|
+
keepAlive = setInterval(() => {
|
|
71
85
|
try {
|
|
72
86
|
res.write(': ping\n\n');
|
|
73
87
|
} catch (e) {
|
|
74
|
-
|
|
88
|
+
cleanup();
|
|
75
89
|
}
|
|
76
90
|
}, 15000);
|
|
91
|
+
if (typeof keepAlive.unref === 'function') {
|
|
92
|
+
keepAlive.unref();
|
|
93
|
+
}
|
|
77
94
|
|
|
78
|
-
req.on('close',
|
|
79
|
-
|
|
80
|
-
|
|
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,56 @@
|
|
|
1
|
+
# Changelog ā `webspresso/plugins/polar`
|
|
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
|
+
|
|
21
|
+
## 1.0.0 ā 2026-09-10
|
|
22
|
+
|
|
23
|
+
First production-ready release.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Zero-dependency Polar SDK (`crypto` + `fetch`): webhook verification, API client, checkout, portal, Customer State sync
|
|
28
|
+
- Auto routes: webhook, checkout (GET/POST), portal, billing status JSON API
|
|
29
|
+
- Idempotent migration generator + `webspresso polar:migrate` CLI
|
|
30
|
+
- Configurable field mapping, tier/plan mapping, hooks (`onSubscriptionChange`)
|
|
31
|
+
- Optional `polarSyncMiddleware` for page-load tier refresh
|
|
32
|
+
- Nunjucks `dateLabel` filter + Date-safe `truncate` wrapper
|
|
33
|
+
- CSP helper (`polarCspDirectives`) for checkout form redirects
|
|
34
|
+
- Zod env schema export (`parseEnv`, `polarEnvSchema`)
|
|
35
|
+
|
|
36
|
+
### Production lessons encoded
|
|
37
|
+
|
|
38
|
+
| Issue | Fix |
|
|
39
|
+
|-------|-----|
|
|
40
|
+
| Checkout URL is often `https://polar.sh/checkout/polar_cst_*`, not only `buy.polar.sh` | `extractCheckoutUrlFromResponse` + `client_secret` builder |
|
|
41
|
+
| `https://polar.sh/purchases/subscriptions` ā 404 | Blocked in URL validators |
|
|
42
|
+
| `subscription.created` with `incomplete` status | Fallback Customer State sync before granting Pro |
|
|
43
|
+
| Metadata may not propagate to subscription | `external_customer_id` required on checkout; multi-strategy user resolver |
|
|
44
|
+
| `polar_current_period_end` as Date breaks Nunjucks `truncate` | Always persist ISO strings; `dateLabel` + safe truncate |
|
|
45
|
+
| Checkout "already subscribed" API error | Pre-checkout `syncPolarBillingForUser` + early redirect |
|
|
46
|
+
| Webhook 401 | Standard Webhooks HMAC (`webhook-id`, `webhook-timestamp`, `whsec_` decode); document raw body requirement |
|
|
47
|
+
| Sandbox vs production OAT | `POLAR_SANDBOX=true` ā `sandbox-api.polar.sh` |
|
|
48
|
+
| Empty metadata fields ā Polar 422 | `buildCheckoutMetadata` omits blank values |
|
|
49
|
+
| Portal deprecated paths | Validate `/{org}/portal` only; fallback `POST /v1/customer-sessions/` |
|
|
50
|
+
|
|
51
|
+
### Migration notes for existing custom Polar integrations
|
|
52
|
+
|
|
53
|
+
- Remove local `utils/polar.js` and file routes under billing/webhook paths ā plugin registers equivalent routes
|
|
54
|
+
- Run `webspresso polar:migrate` (or `plugin.api.generateMigration()`) once
|
|
55
|
+
- Mount `createWebhookRawBodyMiddleware('/api/v1/polar/webhook')` **before** `express.json()` in custom servers for strict signature verification
|
|
56
|
+
- Wire `hooks.onSubscriptionChange` if tier updates need side effects (limits, analytics, etc.)
|