webspresso 0.0.94 → 0.0.96

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.
Files changed (83) hide show
  1. package/.agents/ADMIN.md +225 -0
  2. package/.agents/AGENTS.md +72 -0
  3. package/.agents/ARCHITECTURE.md +119 -0
  4. package/.agents/AUTH.md +103 -0
  5. package/.agents/CLI.md +115 -0
  6. package/.agents/COMPRESSION.md +59 -0
  7. package/.agents/CONVENTIONS.md +46 -0
  8. package/.agents/EMAIL.md +141 -0
  9. package/.agents/ERRORS.md +201 -0
  10. package/.agents/MCP.md +103 -0
  11. package/.agents/ORM.md +230 -0
  12. package/.agents/PLUGINS.md +419 -0
  13. package/.agents/QUEUE.md +113 -0
  14. package/.agents/REALTIME.md +213 -0
  15. package/.agents/ROUTING.md +292 -0
  16. package/.agents/SERVICES.md +341 -0
  17. package/.agents/SHUTDOWN.md +67 -0
  18. package/.agents/STREAMING.md +73 -0
  19. package/.agents/WORKFLOW.md +27 -0
  20. package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
  21. package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
  22. package/.cursorrules +14 -0
  23. package/AGENTS.md +175 -0
  24. package/CLAUDE.md +54 -0
  25. package/README.md +18 -14
  26. package/bin/commands/agents-init.js +169 -0
  27. package/bin/commands/api.js +114 -48
  28. package/bin/commands/doctor.js +35 -0
  29. package/bin/commands/new.js +13 -2
  30. package/bin/commands/page.js +75 -89
  31. package/bin/commands/service.js +77 -0
  32. package/bin/webspresso.js +2 -2
  33. package/core/compileSchema.js +1 -1
  34. package/core/errors/middleware.js +22 -2
  35. package/core/errors/normalize.js +29 -2
  36. package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
  37. package/index.d.ts +306 -0
  38. package/index.js +35 -1
  39. package/package.json +19 -6
  40. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
  41. package/plugins/csrf/index.js +3 -2
  42. package/plugins/csv/engine.js +447 -0
  43. package/plugins/csv/index.js +94 -0
  44. package/plugins/csv/middleware.js +80 -0
  45. package/plugins/csv/services.js +138 -0
  46. package/plugins/file-manager/admin-component.js +613 -0
  47. package/plugins/file-manager/api-handlers.js +261 -0
  48. package/plugins/file-manager/core.js +601 -0
  49. package/plugins/file-manager/index.js +139 -0
  50. package/plugins/file-manager/security.js +207 -0
  51. package/plugins/index.js +8 -0
  52. package/plugins/polar/CHANGELOG.md +18 -0
  53. package/plugins/polar/README.md +30 -7
  54. package/plugins/polar/index.js +6 -1
  55. package/plugins/polar/src/billing-hooks.js +51 -0
  56. package/plugins/polar/src/config.js +2 -0
  57. package/plugins/polar/src/rate-limit.js +96 -28
  58. package/plugins/polar/src/routes.js +3 -2
  59. package/plugins/polar/src/urls.js +26 -2
  60. package/plugins/polar/src/user-resolver.js +5 -1
  61. package/plugins/rate-limit/index.js +5 -1
  62. package/plugins/xlsx/engine.js +397 -0
  63. package/plugins/xlsx/index.js +91 -0
  64. package/plugins/xlsx/middleware.js +87 -0
  65. package/plugins/xlsx/services.js +118 -0
  66. package/src/api/api-loader.js +233 -0
  67. package/src/api/define-api.js +35 -0
  68. package/src/discovery/file-route-parser.js +201 -0
  69. package/src/discovery/scan-routes.js +265 -0
  70. package/src/file-router.js +6 -0
  71. package/src/modules/define-module.js +99 -0
  72. package/src/modules/module-discovery.js +117 -0
  73. package/src/pages/define-page.js +36 -0
  74. package/src/pages/page-loader.js +214 -0
  75. package/src/routing/mount-discovered-routes.js +78 -0
  76. package/src/routing/route-table.js +191 -0
  77. package/src/server.js +112 -15
  78. package/src/services/builtins/file-manager.js +178 -0
  79. package/src/services/index.js +2 -0
  80. package/bin/commands/skill.js +0 -187
  81. package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
  82. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
  83. package/templates/skills/webspresso-usage/SKILL.md +0 -48
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Webspresso File Manager Plugin
3
+ * Filesystem-first media library, directory browser and asset picker for Admin Panel.
4
+ * @module plugins/file-manager
5
+ */
6
+
7
+ const path = require('path');
8
+ const { createFileManagerApiHandlers } = require('./api-handlers');
9
+ const { generateFileManagerComponent } = require('./admin-component');
10
+ const { createFileManagerServices } = require('../../src/services/builtins/file-manager');
11
+ const core = require('./core');
12
+ const security = require('./security');
13
+
14
+ /**
15
+ * @param {Object} [options]
16
+ * @param {string} [options.baseDir='./public/uploads'] - Filesystem directory to manage
17
+ * @param {string} [options.publicBasePath='/uploads'] - URL prefix for stored files
18
+ * @param {string} [options.apiPrefix='/files'] - API subpath under Admin API
19
+ * @param {string} [options.pageTitle='Dosya Yöneticisi'] - Title of admin page
20
+ * @param {string} [options.menuLabel='Dosya Yöneticisi'] - Sidebar menu label
21
+ * @param {number} [options.menuOrder=50] - Sidebar menu ordering
22
+ * @param {number} [options.maxUploadBytes=10485760] - Max upload size in bytes (10MB default)
23
+ * @param {number|null} [options.maxTotalStorageBytes] - Storage quota limit in bytes (optional)
24
+ * @param {string[]|null} [options.allowedExtensions] - Whitelist of allowed extensions
25
+ * @param {string[]|null} [options.allowedMimeTypes] - Whitelist of allowed MIME types
26
+ * @param {Record<string, string[]>|null} [options.permissions] - RBAC permissions map
27
+ * @param {Object} [options.security] - Security configurations
28
+ * @param {boolean} [options.security.validateMagicBytes=true] - Validate file signatures
29
+ * @param {boolean} [options.security.sanitizeSvg=true] - Sanitize SVG XSS payloads
30
+ * @param {boolean} [options.includePage=true] - Whether to register admin menu item & page
31
+ */
32
+ function fileManagerPlugin(options = {}) {
33
+ const {
34
+ baseDir = path.join(process.cwd(), 'public', 'uploads'),
35
+ publicBasePath = '/uploads',
36
+ apiPrefix = '/files',
37
+ pageTitle = 'Dosya Yöneticisi',
38
+ menuLabel = 'Dosya Yöneticisi',
39
+ menuOrder = 50,
40
+ maxUploadBytes = 10 * 1024 * 1024,
41
+ maxTotalStorageBytes = null,
42
+ allowedExtensions = null,
43
+ allowedMimeTypes = null,
44
+ permissions = null,
45
+ security: securityOpts = { validateMagicBytes: true, sanitizeSvg: true },
46
+ includePage = true,
47
+ } = options;
48
+
49
+ const resolvedBaseDir = path.resolve(baseDir);
50
+
51
+ const handlers = createFileManagerApiHandlers({
52
+ baseDir: resolvedBaseDir,
53
+ publicBasePath,
54
+ maxUploadBytes,
55
+ maxTotalStorageBytes,
56
+ allowedExtensions,
57
+ allowedMimeTypes,
58
+ permissions,
59
+ security: securityOpts,
60
+ });
61
+
62
+ return {
63
+ name: 'file-manager',
64
+ version: '1.0.0',
65
+ description: 'Filesystem-first media library, directory explorer & asset picker',
66
+ dependencies: { 'admin-panel': '*' },
67
+
68
+ api: {
69
+ ...core,
70
+ security,
71
+ },
72
+
73
+ register(ctx) {
74
+ // Register built-in services in app service registry if available
75
+ if (ctx.app && ctx.app.serviceRegistry) {
76
+ const services = createFileManagerServices({
77
+ baseDir: resolvedBaseDir,
78
+ publicBasePath,
79
+ });
80
+ for (const [name, def] of Object.entries(services)) {
81
+ if (!ctx.app.serviceRegistry.has(name)) {
82
+ ctx.app.serviceRegistry.register(name, def);
83
+ }
84
+ }
85
+ }
86
+ },
87
+
88
+ onRoutesReady(ctx) {
89
+ const adminApi = ctx.usePlugin('admin-panel');
90
+ if (!adminApi) {
91
+ console.warn('[file-manager] admin-panel plugin not found, skipping Admin UI registration');
92
+ return;
93
+ }
94
+
95
+ adminApi.registerModule({
96
+ id: 'files',
97
+ pages: includePage
98
+ ? [{
99
+ id: 'files',
100
+ title: pageTitle,
101
+ path: '/files',
102
+ icon: 'folder',
103
+ component: generateFileManagerComponent({
104
+ apiPrefix,
105
+ publicBasePath,
106
+ }),
107
+ }]
108
+ : [],
109
+
110
+ menu: includePage
111
+ ? [{
112
+ id: 'files',
113
+ label: menuLabel,
114
+ path: '/files',
115
+ icon: 'folder',
116
+ order: menuOrder,
117
+ }]
118
+ : [],
119
+
120
+ api: {
121
+ prefix: apiPrefix,
122
+ routes: [
123
+ { method: 'get', path: '', handler: handlers.list },
124
+ { method: 'post', path: '/mkdir', handler: handlers.mkdir },
125
+ { method: 'post', path: '/rename', handler: handlers.rename },
126
+ { method: 'post', path: '/move', handler: handlers.move },
127
+ { method: 'post', path: '/delete', handler: handlers.delete },
128
+ { method: 'post', path: '/upload', handler: handlers.upload },
129
+ ],
130
+ },
131
+ });
132
+ },
133
+ };
134
+ }
135
+
136
+ module.exports = fileManagerPlugin;
137
+ module.exports.fileManagerPlugin = fileManagerPlugin;
138
+ module.exports.core = core;
139
+ module.exports.security = security;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * File Manager Security Subsystem — Magic byte verification, dangerous extension blocking & SVG sanitization.
3
+ * Zero external dependencies, ultra-high performance O(1) buffer checks.
4
+ * @module plugins/file-manager/security
5
+ */
6
+
7
+ const { SecurityError, ValidationError } = require('../../core/errors');
8
+
9
+ /**
10
+ * Universal list of dangerous/executable extensions (lowercase without dot)
11
+ */
12
+ const DANGEROUS_EXTENSIONS = new Set([
13
+ // Scripts & Executables
14
+ 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'phar',
15
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'elf',
16
+ 'sh', 'bash', 'zsh', 'csh', 'ksh', 'fish', 'bat', 'cmd', 'ps1', 'psm1',
17
+ 'vbs', 'vbe', 'wsf', 'wsh', 'scr', 'cpl', 'msc', 'hta',
18
+ 'jar', 'war', 'ear', 'class',
19
+ 'jsp', 'jspx', 'jsw', 'jsv', 'jspf',
20
+ 'asp', 'aspx', 'axd', 'asx', 'ashx', 'asmx',
21
+ 'cgi', 'pl', 'pm', 'py', 'pyc', 'pyo', 'rb',
22
+ // System & Config files
23
+ 'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
24
+ ]);
25
+
26
+ /**
27
+ * Known magic numbers table for high-speed signature verification.
28
+ * Only validates when extension matches known binary format.
29
+ */
30
+ const MAGIC_NUMBERS = {
31
+ // JPEG / JPG
32
+ jpg: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff,
33
+ jpeg: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff,
34
+
35
+ // PNG
36
+ png: (buf) =>
37
+ buf.length >= 8 &&
38
+ buf[0] === 0x89 &&
39
+ buf[1] === 0x50 &&
40
+ buf[2] === 0x4e &&
41
+ buf[3] === 0x47 &&
42
+ buf[4] === 0x0d &&
43
+ buf[5] === 0x0a &&
44
+ buf[6] === 0x1a &&
45
+ buf[7] === 0x0a,
46
+
47
+ // GIF
48
+ gif: (buf) =>
49
+ buf.length >= 6 &&
50
+ buf[0] === 0x47 &&
51
+ buf[1] === 0x49 &&
52
+ buf[2] === 0x46 &&
53
+ buf[3] === 0x38 &&
54
+ (buf[4] === 0x37 || buf[4] === 0x39) &&
55
+ buf[5] === 0x61,
56
+
57
+ // WebP: RIFF at 0..3 and WEBP at 8..11
58
+ webp: (buf) =>
59
+ buf.length >= 12 &&
60
+ buf[0] === 0x52 &&
61
+ buf[1] === 0x49 &&
62
+ buf[2] === 0x46 &&
63
+ buf[3] === 0x46 &&
64
+ buf[8] === 0x57 &&
65
+ buf[9] === 0x45 &&
66
+ buf[10] === 0x42 &&
67
+ buf[11] === 0x50,
68
+
69
+ // PDF: %PDF-
70
+ pdf: (buf) =>
71
+ buf.length >= 5 &&
72
+ buf[0] === 0x25 &&
73
+ buf[1] === 0x50 &&
74
+ buf[2] === 0x44 &&
75
+ buf[3] === 0x46 &&
76
+ buf[4] === 0x2d,
77
+
78
+ // ZIP (and DOCX/XLSX/PPTX)
79
+ zip: (buf) =>
80
+ buf.length >= 4 &&
81
+ buf[0] === 0x50 &&
82
+ buf[1] === 0x4b &&
83
+ (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07) &&
84
+ (buf[3] === 0x04 || buf[3] === 0x06 || buf[3] === 0x08),
85
+ docx: (buf) => MAGIC_NUMBERS.zip(buf),
86
+ xlsx: (buf) => MAGIC_NUMBERS.zip(buf),
87
+ pptx: (buf) => MAGIC_NUMBERS.zip(buf),
88
+
89
+ // MP3: ID3 or frame sync 0xFFE / 0xFFF
90
+ mp3: (buf) =>
91
+ (buf.length >= 3 && buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) ||
92
+ (buf.length >= 2 && buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0),
93
+
94
+ // MP4: ....ftyp at bytes 4..7
95
+ mp4: (buf) =>
96
+ buf.length >= 8 &&
97
+ buf[4] === 0x66 &&
98
+ buf[5] === 0x74 &&
99
+ buf[6] === 0x79 &&
100
+ buf[7] === 0x70,
101
+ };
102
+
103
+ /**
104
+ * Validate that a filename contains no dangerous extensions or double-extension tricks.
105
+ * @param {string} filename
106
+ * @returns {string} Clean lowercase extension
107
+ */
108
+ function validateSafeExtension(filename) {
109
+ if (!filename || typeof filename !== 'string') {
110
+ throw new ValidationError('Filename is required');
111
+ }
112
+
113
+ const cleanName = filename.toLowerCase().trim();
114
+ const parts = cleanName.split('.').filter(Boolean);
115
+
116
+ if (parts.length === 0) {
117
+ throw new ValidationError('Filename must have a valid extension');
118
+ }
119
+
120
+ // Check all dotted segments against dangerous extensions to block double extensions (e.g. payload.php.jpg)
121
+ for (let i = 1; i < parts.length; i++) {
122
+ const segment = parts[i];
123
+ if (DANGEROUS_EXTENSIONS.has(segment)) {
124
+ throw new SecurityError(
125
+ `Dangerous extension or double-extension detected: .${segment}`,
126
+ 400
127
+ );
128
+ }
129
+ }
130
+
131
+ const finalExt = parts[parts.length - 1];
132
+ if (DANGEROUS_EXTENSIONS.has(finalExt)) {
133
+ throw new SecurityError(`File type .${finalExt} is forbidden for upload`, 403);
134
+ }
135
+
136
+ return finalExt;
137
+ }
138
+
139
+ /**
140
+ * Verify that the buffer's magic bytes match the claimed extension.
141
+ * O(1) time complexity, minimal memory overhead.
142
+ * @param {Buffer} buffer
143
+ * @param {string} ext
144
+ * @returns {boolean}
145
+ */
146
+ function validateMagicBytes(buffer, ext) {
147
+ if (!buffer || !Buffer.isBuffer(buffer)) {
148
+ throw new ValidationError('Valid buffer is required for magic byte check');
149
+ }
150
+
151
+ const cleanExt = (ext || '').replace(/^\./, '').toLowerCase();
152
+ const validator = MAGIC_NUMBERS[cleanExt];
153
+
154
+ if (validator) {
155
+ const isValid = validator(buffer);
156
+ if (!isValid) {
157
+ throw new SecurityError(
158
+ `File signature mismatch: file content does not match expected .${cleanExt} format`,
159
+ 400
160
+ );
161
+ }
162
+ }
163
+
164
+ return true;
165
+ }
166
+
167
+ /**
168
+ * Sanitize SVG content against Stored XSS attacks.
169
+ * Strips <script>, <foreignObject>, <iframe>, <embed>, <object> and on* event handlers.
170
+ * @param {Buffer|string} content
171
+ * @returns {Buffer} Sanitized SVG buffer
172
+ */
173
+ function sanitizeSvgContent(content) {
174
+ const str = Buffer.isBuffer(content) ? content.toString('utf8') : String(content);
175
+
176
+ // Quick check: if no dangerous keywords, return original buffer directly for maximum speed
177
+ if (!/<(script|foreignobject|iframe|embed|object|meta|link|style)/i.test(str) &&
178
+ !/\s+on[a-z]+\s*=/i.test(str) &&
179
+ !/javascript:/i.test(str) &&
180
+ !/data:text\/html/i.test(str)) {
181
+ return Buffer.isBuffer(content) ? content : Buffer.from(str, 'utf8');
182
+ }
183
+
184
+ let sanitized = str
185
+ // Remove script tags and content
186
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '')
187
+ // Remove dangerous containers
188
+ .replace(/<foreignObject\b[^<]*(?:(?!<\/foreignObject>)<[^<]*)*<\/foreignObject\s*>/gi, '')
189
+ .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '')
190
+ .replace(/<embed\b[^>]*>/gi, '')
191
+ .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '')
192
+ // Remove inline event handlers (onload, onclick, onerror, onmouseover, etc.)
193
+ .replace(/\s+on[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
194
+ // Remove javascript: and data:text/html URIs in href and xlink:href
195
+ .replace(/(?:href|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, 'href=""')
196
+ .replace(/(?:href|xlink:href)\s*=\s*(?:"\s*data:text\/html[^"]*"|'\s*data:text\/html[^']*')/gi, 'href=""');
197
+
198
+ return Buffer.from(sanitized, 'utf8');
199
+ }
200
+
201
+ module.exports = {
202
+ DANGEROUS_EXTENSIONS,
203
+ MAGIC_NUMBERS,
204
+ validateSafeExtension,
205
+ validateMagicBytes,
206
+ sanitizeSvgContent,
207
+ };
package/plugins/index.js CHANGED
@@ -41,6 +41,9 @@ const {
41
41
  } = require('./realtime');
42
42
  const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
43
43
  const polarPlugin = require('./polar');
44
+ const fileManagerPlugin = require('./file-manager');
45
+ const { xlsxPlugin, xlsx } = require('./xlsx');
46
+ const { csvPlugin, csv } = require('./csv');
44
47
 
45
48
  module.exports = {
46
49
  sitemapPlugin,
@@ -83,6 +86,11 @@ module.exports = {
83
86
  createMcpServer,
84
87
  McpServer,
85
88
  polarPlugin,
89
+ fileManagerPlugin,
90
+ xlsxPlugin,
91
+ xlsx,
92
+ csvPlugin,
93
+ csv,
86
94
  };
87
95
 
88
96
 
@@ -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,