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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso MCP Stdio Transport
|
|
3
|
+
* Runs MCP server over standard input and standard output (JSON-RPC 2.0 lines).
|
|
4
|
+
* Redirects console.log to process.stderr to prevent JSON-RPC stream corruption.
|
|
5
|
+
* @module plugins/mcp/transports/stdio
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
const { ERROR_CODES, createJsonRpcError } = require('../server');
|
|
10
|
+
|
|
11
|
+
let originalConsoleLog = null;
|
|
12
|
+
let originalConsoleInfo = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Redirects standard console logs to stderr
|
|
16
|
+
*/
|
|
17
|
+
function redirectConsoleToStderr() {
|
|
18
|
+
if (originalConsoleLog) return;
|
|
19
|
+
originalConsoleLog = console.log;
|
|
20
|
+
originalConsoleInfo = console.info;
|
|
21
|
+
|
|
22
|
+
console.log = (...args) => {
|
|
23
|
+
process.stderr.write(`${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}\n`);
|
|
24
|
+
};
|
|
25
|
+
console.info = (...args) => {
|
|
26
|
+
process.stderr.write(`${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ')}\n`);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Restores original console log functions
|
|
32
|
+
*/
|
|
33
|
+
function restoreConsole() {
|
|
34
|
+
if (originalConsoleLog) {
|
|
35
|
+
console.log = originalConsoleLog;
|
|
36
|
+
originalConsoleLog = null;
|
|
37
|
+
}
|
|
38
|
+
if (originalConsoleInfo) {
|
|
39
|
+
console.info = originalConsoleInfo;
|
|
40
|
+
originalConsoleInfo = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Starts Stdio transport for an MCP Server
|
|
46
|
+
* @param {import('../server').McpServer} server
|
|
47
|
+
* @param {Object} [options]
|
|
48
|
+
* @param {ReadableStream} [options.input=process.stdin]
|
|
49
|
+
* @param {WritableStream} [options.output=process.stdout]
|
|
50
|
+
* @param {boolean} [options.redirectStderr=true]
|
|
51
|
+
* @returns {{ close: Function, rl: readline.Interface }}
|
|
52
|
+
*/
|
|
53
|
+
function startStdioTransport(server, options = {}) {
|
|
54
|
+
const input = options.input || process.stdin;
|
|
55
|
+
const output = options.output || process.stdout;
|
|
56
|
+
const shouldRedirect = options.redirectStderr !== false;
|
|
57
|
+
|
|
58
|
+
if (shouldRedirect) {
|
|
59
|
+
redirectConsoleToStderr();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rl = readline.createInterface({
|
|
63
|
+
input,
|
|
64
|
+
output: null,
|
|
65
|
+
terminal: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sendResponse = (msg) => {
|
|
69
|
+
if (msg) {
|
|
70
|
+
output.write(`${JSON.stringify(msg)}\n`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
rl.on('line', async (line) => {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed) return;
|
|
77
|
+
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(trimmed);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
sendResponse(createJsonRpcError(null, ERROR_CODES.PARSE_ERROR, `Parse error: ${err.message}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await server.handleMessage(parsed);
|
|
88
|
+
sendResponse(response);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
sendResponse(createJsonRpcError(parsed?.id || null, ERROR_CODES.INTERNAL_ERROR, err.message));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const close = () => {
|
|
95
|
+
rl.close();
|
|
96
|
+
if (shouldRedirect) {
|
|
97
|
+
restoreConsole();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return { close, rl };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
startStdioTransport,
|
|
106
|
+
redirectConsoleToStderr,
|
|
107
|
+
restoreConsole,
|
|
108
|
+
};
|
|
@@ -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,38 @@
|
|
|
1
|
+
# Changelog — `webspresso/plugins/polar`
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-09-10
|
|
4
|
+
|
|
5
|
+
First production-ready release.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Zero-dependency Polar SDK (`crypto` + `fetch`): webhook verification, API client, checkout, portal, Customer State sync
|
|
10
|
+
- Auto routes: webhook, checkout (GET/POST), portal, billing status JSON API
|
|
11
|
+
- Idempotent migration generator + `webspresso polar:migrate` CLI
|
|
12
|
+
- Configurable field mapping, tier/plan mapping, hooks (`onSubscriptionChange`)
|
|
13
|
+
- Optional `polarSyncMiddleware` for page-load tier refresh
|
|
14
|
+
- Nunjucks `dateLabel` filter + Date-safe `truncate` wrapper
|
|
15
|
+
- CSP helper (`polarCspDirectives`) for checkout form redirects
|
|
16
|
+
- Zod env schema export (`parseEnv`, `polarEnvSchema`)
|
|
17
|
+
|
|
18
|
+
### Production lessons encoded
|
|
19
|
+
|
|
20
|
+
| Issue | Fix |
|
|
21
|
+
|-------|-----|
|
|
22
|
+
| Checkout URL is often `https://polar.sh/checkout/polar_cst_*`, not only `buy.polar.sh` | `extractCheckoutUrlFromResponse` + `client_secret` builder |
|
|
23
|
+
| `https://polar.sh/purchases/subscriptions` → 404 | Blocked in URL validators |
|
|
24
|
+
| `subscription.created` with `incomplete` status | Fallback Customer State sync before granting Pro |
|
|
25
|
+
| Metadata may not propagate to subscription | `external_customer_id` required on checkout; multi-strategy user resolver |
|
|
26
|
+
| `polar_current_period_end` as Date breaks Nunjucks `truncate` | Always persist ISO strings; `dateLabel` + safe truncate |
|
|
27
|
+
| Checkout "already subscribed" API error | Pre-checkout `syncPolarBillingForUser` + early redirect |
|
|
28
|
+
| Webhook 401 | Standard Webhooks HMAC (`webhook-id`, `webhook-timestamp`, `whsec_` decode); document raw body requirement |
|
|
29
|
+
| Sandbox vs production OAT | `POLAR_SANDBOX=true` → `sandbox-api.polar.sh` |
|
|
30
|
+
| Empty metadata fields → Polar 422 | `buildCheckoutMetadata` omits blank values |
|
|
31
|
+
| Portal deprecated paths | Validate `/{org}/portal` only; fallback `POST /v1/customer-sessions/` |
|
|
32
|
+
|
|
33
|
+
### Migration notes for existing custom Polar integrations
|
|
34
|
+
|
|
35
|
+
- Remove local `utils/polar.js` and file routes under billing/webhook paths — plugin registers equivalent routes
|
|
36
|
+
- Run `webspresso polar:migrate` (or `plugin.api.generateMigration()`) once
|
|
37
|
+
- Mount `createWebhookRawBodyMiddleware('/api/v1/polar/webhook')` **before** `express.json()` in custom servers for strict signature verification
|
|
38
|
+
- Wire `hooks.onSubscriptionChange` if tier updates need side effects (limits, analytics, etc.)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Polar.sh Billing Plugin for Webspresso
|
|
2
|
+
|
|
3
|
+
Production-ready Polar subscription billing for Webspresso. Zero extra dependencies (`crypto` + native `fetch` only).
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
### 1. Environment
|
|
8
|
+
|
|
9
|
+
Copy `plugins/polar/.env.example` → `.env`:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
POLAR_ACCESS_TOKEN=polar_oat_...
|
|
13
|
+
POLAR_WEBHOOK_SECRET=whsec_...
|
|
14
|
+
POLAR_PRO_PRODUCT_ID=<product-uuid-not-price-id>
|
|
15
|
+
POLAR_ORG_SLUG=your-org
|
|
16
|
+
POLAR_SANDBOX=false
|
|
17
|
+
BASE_URL=https://example.com
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 2. Migration
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
webspresso polar:migrate
|
|
24
|
+
webspresso db:migrate
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 3. Plugin config (~3 lines)
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const { polarPlugin, rateLimitPlugin } = require('webspresso/plugins');
|
|
31
|
+
const { quickAuth } = require('webspresso/core/auth');
|
|
32
|
+
|
|
33
|
+
createApp({
|
|
34
|
+
db,
|
|
35
|
+
auth: quickAuth({ db, jwt: true }),
|
|
36
|
+
plugins: [
|
|
37
|
+
rateLimitPlugin(), // optional but recommended — polar applies per-route limits
|
|
38
|
+
polarPlugin({
|
|
39
|
+
db,
|
|
40
|
+
userModel: 'User',
|
|
41
|
+
plans: { pro_monthly: process.env.POLAR_PRO_PRODUCT_ID },
|
|
42
|
+
tierMapping: { pro: ['pro_monthly'], free: [] },
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it — routes, webhooks, checkout, portal, and status API are auto-mounted.
|
|
49
|
+
|
|
50
|
+
### 4. Polar Dashboard webhook
|
|
51
|
+
|
|
52
|
+
- **URL:** `https://your-domain.com/api/v1/polar/webhook`
|
|
53
|
+
- **Format:** Raw (not Slack/Discord)
|
|
54
|
+
- **Events:** `customer.state_changed`, `subscription.*`, `order.*`, `checkout.updated`
|
|
55
|
+
|
|
56
|
+
## Routes (defaults, all configurable)
|
|
57
|
+
|
|
58
|
+
| Method | Path | Auth | Behavior |
|
|
59
|
+
|--------|------|------|----------|
|
|
60
|
+
| POST | `/api/v1/polar/webhook` | HMAC signature | Process Polar events |
|
|
61
|
+
| GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect |
|
|
62
|
+
| GET | `/settings/billing/portal` | session/JWT | Customer portal redirect |
|
|
63
|
+
| GET | `/api/v1/billing/status` | session/JWT | Sync + JSON billing status |
|
|
64
|
+
|
|
65
|
+
## Template usage
|
|
66
|
+
|
|
67
|
+
```njk
|
|
68
|
+
{% if user.tier == 'pro' %}
|
|
69
|
+
<p>Pro until {{ user.polar_current_period_end | dateLabel }}</p>
|
|
70
|
+
<a href="/settings/billing/portal">Manage subscription</a>
|
|
71
|
+
{% else %}
|
|
72
|
+
<form method="post" action="/settings/billing/checkout">
|
|
73
|
+
<button type="submit">Upgrade to Pro</button>
|
|
74
|
+
</form>
|
|
75
|
+
{% endif %}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## SDK exports (`plugin.api` or direct imports)
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
const polar = polarPlugin({ db });
|
|
82
|
+
polar.api.verifyPolarWebhook(raw, headers, secret);
|
|
83
|
+
polar.api.syncPolarBillingForUser(user, knex);
|
|
84
|
+
polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
|
|
85
|
+
polar.api.handlePolarWebhookEvent(event, knex);
|
|
86
|
+
polar.api.generateMigration();
|
|
87
|
+
polar.api.parseEnv(process.env);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Webhook raw body (production)
|
|
91
|
+
|
|
92
|
+
For strict HMAC verification, mount **before** `express.json()`:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const { createWebhookRawBodyMiddleware } = require('webspresso/plugins/polar/src/webhooks');
|
|
96
|
+
|
|
97
|
+
app.use(createWebhookRawBodyMiddleware('/api/v1/polar/webhook'));
|
|
98
|
+
app.use(express.json());
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Without this, the plugin falls back to `JSON.stringify(req.body)` (works for most cases; key order edge cases possible).
|
|
102
|
+
|
|
103
|
+
## Rate limiting
|
|
104
|
+
|
|
105
|
+
Enabled by default (`rateLimit: true`). Requires `rateLimitPlugin()` registered **before** `polarPlugin`:
|
|
106
|
+
|
|
107
|
+
| Route | Default |
|
|
108
|
+
|-------|---------|
|
|
109
|
+
| Webhook | 120 req/min (by IP) |
|
|
110
|
+
| Checkout | 5 req/min (by user) |
|
|
111
|
+
| Portal | 10 req/min (by user) |
|
|
112
|
+
| Status | 60 req/min (by user) |
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
polarPlugin({ db, rateLimit: false }); // disable
|
|
116
|
+
polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Optional hooks
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
polarPlugin({
|
|
123
|
+
db,
|
|
124
|
+
hooks: {
|
|
125
|
+
onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
|
|
126
|
+
// Custom tier logic (default: updates users.* polar columns)
|
|
127
|
+
return knex('users').where('id', user.id).update(update);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
syncMiddleware: {
|
|
131
|
+
when: (req) => req.query.tab === 'billing',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## CSP (Helmet)
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const { polarCspDirectives } = require('webspresso/plugins/polar/src/urls');
|
|
140
|
+
|
|
141
|
+
createApp({
|
|
142
|
+
helmet: {
|
|
143
|
+
contentSecurityPolicy: {
|
|
144
|
+
directives: {
|
|
145
|
+
...polarCspDirectives(),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Or rely on the plugin's built-in `csp` export (auto-merged by plugin manager).
|
|
153
|
+
|
|
154
|
+
## Non-goals (v1)
|
|
155
|
+
|
|
156
|
+
- Admin UI (use Polar dashboard)
|
|
157
|
+
- Multi-org / team seat billing
|
|
158
|
+
- `@polar-sh/sdk` dependency
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Polar.sh billing plugin for Webspresso
|
|
5
|
+
* @module plugins/polar
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { resolvePolarConfig } = require('./src/config');
|
|
9
|
+
const { polarEnvSchema, parsePolarEnv } = require('./src/env.schema');
|
|
10
|
+
const { polarApiRequest } = require('./src/api');
|
|
11
|
+
const { verifyPolarWebhook, handlePolarWebhookEvent, createWebhookRawBodyMiddleware } = require('./src/webhooks');
|
|
12
|
+
const { getPolarCheckoutUrl } = require('./src/checkout');
|
|
13
|
+
const { getPolarPortalUrl } = require('./src/portal');
|
|
14
|
+
const { syncPolarBillingForUser } = require('./src/sync');
|
|
15
|
+
const {
|
|
16
|
+
resolveUserFromPolarData,
|
|
17
|
+
buildCheckoutMetadata,
|
|
18
|
+
isProSubscriptionStatus,
|
|
19
|
+
userHasProTier,
|
|
20
|
+
pickBestActiveSubscription,
|
|
21
|
+
} = require('./src/user-resolver');
|
|
22
|
+
const {
|
|
23
|
+
isValidCheckoutRedirectUrl,
|
|
24
|
+
isValidPortalRedirectUrl,
|
|
25
|
+
extractCheckoutUrlFromResponse,
|
|
26
|
+
polarCspDirectives,
|
|
27
|
+
} = require('./src/urls');
|
|
28
|
+
const { generatePolarMigration } = require('./src/migration');
|
|
29
|
+
const { polarSyncMiddleware } = require('./src/middleware');
|
|
30
|
+
const { registerNunjucksFilters, dateLabel } = require('./src/nunjucks-filters');
|
|
31
|
+
const {
|
|
32
|
+
createInjectDb,
|
|
33
|
+
createWebhookHandler,
|
|
34
|
+
createCheckoutHandler,
|
|
35
|
+
createPortalHandler,
|
|
36
|
+
createStatusHandler,
|
|
37
|
+
createRequireAuth,
|
|
38
|
+
} = require('./src/routes');
|
|
39
|
+
const { resolvePolarRateLimiters, DEFAULT_RATE_LIMITS } = require('./src/rate-limit');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {Object} options
|
|
43
|
+
* @param {import('../../core/orm/database').Database} options.db
|
|
44
|
+
* @param {string} [options.userModel='User']
|
|
45
|
+
* @param {string} [options.userTable='users']
|
|
46
|
+
* @param {Record<string, string>} [options.plans]
|
|
47
|
+
* @param {Record<string, string[]>} [options.tierMapping]
|
|
48
|
+
* @param {Object} [options.routes]
|
|
49
|
+
* @param {Object} [options.urls]
|
|
50
|
+
* @param {Object} [options.fields]
|
|
51
|
+
* @param {Object} [options.hooks]
|
|
52
|
+
* @param {Function} [options.requireAuth]
|
|
53
|
+
* @param {boolean} [options.enabled=true]
|
|
54
|
+
*/
|
|
55
|
+
function polarPlugin(options = {}) {
|
|
56
|
+
const config = resolvePolarConfig(options);
|
|
57
|
+
let runtimeConfig = config;
|
|
58
|
+
|
|
59
|
+
const sdk = {
|
|
60
|
+
verifyPolarWebhook,
|
|
61
|
+
polarApiRequest: (path, opts = {}) => polarApiRequest(path, {
|
|
62
|
+
...opts,
|
|
63
|
+
accessToken: opts.accessToken || runtimeConfig.accessToken,
|
|
64
|
+
sandbox: opts.sandbox !== undefined ? opts.sandbox : runtimeConfig.sandbox,
|
|
65
|
+
}),
|
|
66
|
+
getPolarCheckoutUrl: (params) => getPolarCheckoutUrl({ ...params, config: runtimeConfig }),
|
|
67
|
+
getPolarPortalUrl: (user, baseUrl) => getPolarPortalUrl(user, baseUrl, runtimeConfig),
|
|
68
|
+
syncPolarBillingForUser: (user, knex) => syncPolarBillingForUser(user, knex, runtimeConfig),
|
|
69
|
+
handlePolarWebhookEvent: (event, knex, hooks) => handlePolarWebhookEvent(event, knex, runtimeConfig, hooks),
|
|
70
|
+
resolveUserFromPolarData: (data, knex) => resolveUserFromPolarData(data, knex, runtimeConfig),
|
|
71
|
+
extractCheckoutUrlFromResponse: (data) => extractCheckoutUrlFromResponse(data, runtimeConfig.sandbox),
|
|
72
|
+
isValidCheckoutRedirectUrl,
|
|
73
|
+
isValidPortalRedirectUrl,
|
|
74
|
+
buildCheckoutMetadata: (user, customFields) => buildCheckoutMetadata(user, runtimeConfig, customFields),
|
|
75
|
+
userHasProTier: (user) => userHasProTier(user, runtimeConfig),
|
|
76
|
+
isProSubscriptionStatus,
|
|
77
|
+
pickBestActiveSubscription: (subs) => pickBestActiveSubscription(subs, runtimeConfig),
|
|
78
|
+
generateMigration: (opts) => generatePolarMigration({ ...opts, fields: runtimeConfig.fields }),
|
|
79
|
+
polarSyncMiddleware: (opts) => polarSyncMiddleware(runtimeConfig, opts),
|
|
80
|
+
polarCspDirectives,
|
|
81
|
+
createWebhookRawBodyMiddleware,
|
|
82
|
+
registerNunjucksFilters,
|
|
83
|
+
dateLabel,
|
|
84
|
+
parseEnv: parsePolarEnv,
|
|
85
|
+
envSchema: polarEnvSchema,
|
|
86
|
+
getConfig: () => ({ ...runtimeConfig }),
|
|
87
|
+
defaultRateLimits: DEFAULT_RATE_LIMITS,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name: 'polar',
|
|
92
|
+
version: '1.0.0',
|
|
93
|
+
description: 'Polar.sh subscription billing — checkout, portal, webhooks, and tier sync',
|
|
94
|
+
|
|
95
|
+
csp: polarCspDirectives(),
|
|
96
|
+
|
|
97
|
+
api: sdk,
|
|
98
|
+
|
|
99
|
+
register(ctx) {
|
|
100
|
+
runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
|
|
101
|
+
if (!runtimeConfig.enabled) return;
|
|
102
|
+
|
|
103
|
+
registerNunjucksFilters(ctx.nunjucksEnv);
|
|
104
|
+
|
|
105
|
+
if (runtimeConfig.syncMiddleware) {
|
|
106
|
+
ctx.app.use(polarSyncMiddleware(runtimeConfig));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
onRoutesReady(ctx) {
|
|
111
|
+
runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
|
|
112
|
+
if (!runtimeConfig.enabled) return;
|
|
113
|
+
|
|
114
|
+
const db = runtimeConfig.db || ctx.db;
|
|
115
|
+
if (!db) {
|
|
116
|
+
console.warn('[polar] Skipping routes: no database (pass options.db or createApp({ db }))');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const injectDb = createInjectDb(db);
|
|
121
|
+
const requireAuth = createRequireAuth(runtimeConfig, ctx);
|
|
122
|
+
const { routes } = runtimeConfig;
|
|
123
|
+
const rl = resolvePolarRateLimiters(ctx, runtimeConfig);
|
|
124
|
+
|
|
125
|
+
const chain = (...handlers) => handlers.filter(Boolean);
|
|
126
|
+
|
|
127
|
+
ctx.addRoute('post', routes.webhook, ...chain(injectDb, rl.webhook, createWebhookHandler(runtimeConfig)));
|
|
128
|
+
ctx.addRoute('post', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
|
|
129
|
+
ctx.addRoute('get', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
|
|
130
|
+
ctx.addRoute('get', routes.portal, ...chain(injectDb, requireAuth, rl.portal, createPortalHandler(runtimeConfig)));
|
|
131
|
+
ctx.addRoute('get', routes.status, ...chain(injectDb, requireAuth, rl.status, createStatusHandler(runtimeConfig)));
|
|
132
|
+
|
|
133
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
134
|
+
console.log(` Polar billing: POST ${routes.webhook}`);
|
|
135
|
+
console.log(` Polar billing: GET/POST ${routes.checkout}`);
|
|
136
|
+
console.log(` Polar billing: GET ${routes.portal}`);
|
|
137
|
+
console.log(` Polar billing: GET ${routes.status}`);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = polarPlugin;
|
|
144
|
+
module.exports.polarPlugin = polarPlugin;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function getPolarApiBase(sandbox) {
|
|
4
|
+
return sandbox ? 'https://sandbox-api.polar.sh/v1' : 'https://api.polar.sh/v1';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} path
|
|
9
|
+
* @param {Object} opts
|
|
10
|
+
* @param {string} opts.method
|
|
11
|
+
* @param {Object} [opts.body]
|
|
12
|
+
* @param {string} opts.accessToken
|
|
13
|
+
* @param {boolean} [opts.sandbox=false]
|
|
14
|
+
*/
|
|
15
|
+
async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false } = {}) {
|
|
16
|
+
if (!accessToken) {
|
|
17
|
+
throw new Error('POLAR_ACCESS_TOKEN is not configured');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
21
|
+
const res = await fetch(`${getPolarApiBase(sandbox)}${normalizedPath}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${accessToken}`,
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
Accept: 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const raw = await res.text();
|
|
32
|
+
let data = null;
|
|
33
|
+
if (raw) {
|
|
34
|
+
try {
|
|
35
|
+
data = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
data = { raw };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const detail = typeof data?.detail === 'string'
|
|
43
|
+
? data.detail
|
|
44
|
+
: JSON.stringify(data?.detail || data?.error || data?.error_description || raw).slice(0, 500);
|
|
45
|
+
throw new Error(`Polar API ${res.status}: ${detail}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return data || {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
getPolarApiBase,
|
|
53
|
+
polarApiRequest,
|
|
54
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { polarApiRequest } = require('./api');
|
|
4
|
+
const { buildCheckoutMetadata } = require('./user-resolver');
|
|
5
|
+
const {
|
|
6
|
+
extractCheckoutUrlFromResponse,
|
|
7
|
+
isValidCheckoutRedirectUrl,
|
|
8
|
+
getPolarBuyBase,
|
|
9
|
+
} = require('./urls');
|
|
10
|
+
|
|
11
|
+
function resolvePlanProductId(plan, config) {
|
|
12
|
+
if (config.plans[plan]) return config.plans[plan];
|
|
13
|
+
if (plan === 'pro_yearly') {
|
|
14
|
+
return config.plans.pro_yearly || config.plans.pro_monthly;
|
|
15
|
+
}
|
|
16
|
+
return config.plans.pro_monthly;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveHostedCheckoutBaseUrl(config) {
|
|
20
|
+
if (config.checkoutUrl) return config.checkoutUrl;
|
|
21
|
+
|
|
22
|
+
const productId = resolvePlanProductId('pro_monthly', config);
|
|
23
|
+
if (productId && productId.startsWith('polar_cl_')) {
|
|
24
|
+
return `${getPolarBuyBase(config.sandbox)}/${productId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildHostedCheckoutUrl(hostedUrl, user, config) {
|
|
31
|
+
const url = new URL(hostedUrl);
|
|
32
|
+
const f = config.fields;
|
|
33
|
+
if (user[f.email]) url.searchParams.set('email', user[f.email]);
|
|
34
|
+
url.searchParams.set('metadata[user_id]', String(user[f.id]));
|
|
35
|
+
if (user[f.publicId]) url.searchParams.set('metadata[public_id]', user[f.publicId]);
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function createCheckoutSessionViaApi({ user, productId, baseUrl, config }) {
|
|
40
|
+
const metadata = buildCheckoutMetadata(user, config);
|
|
41
|
+
const f = config.fields;
|
|
42
|
+
const shared = {
|
|
43
|
+
success_url: config.urls.success.startsWith('http')
|
|
44
|
+
? config.urls.success
|
|
45
|
+
: `${baseUrl}${config.urls.success}`,
|
|
46
|
+
return_url: config.urls.return.startsWith('http')
|
|
47
|
+
? config.urls.return
|
|
48
|
+
: `${baseUrl}${config.urls.return}`,
|
|
49
|
+
metadata,
|
|
50
|
+
external_customer_id: String(user[f.publicId] || user[f.id]),
|
|
51
|
+
};
|
|
52
|
+
if (user[f.email]) shared.customer_email = user[f.email];
|
|
53
|
+
|
|
54
|
+
const attempts = [{ products: [productId], ...shared }];
|
|
55
|
+
|
|
56
|
+
if (config.priceId) {
|
|
57
|
+
attempts.push({
|
|
58
|
+
product_id: productId,
|
|
59
|
+
product_price_id: config.priceId,
|
|
60
|
+
customer_email: user[f.email] || undefined,
|
|
61
|
+
success_url: shared.success_url,
|
|
62
|
+
metadata,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const apiOpts = { accessToken: config.accessToken, sandbox: config.sandbox };
|
|
67
|
+
let lastError = null;
|
|
68
|
+
|
|
69
|
+
for (const payload of attempts) {
|
|
70
|
+
try {
|
|
71
|
+
const data = await polarApiRequest('/checkouts/', { method: 'POST', body: payload, ...apiOpts });
|
|
72
|
+
const checkoutUrl = extractCheckoutUrlFromResponse(data, config.sandbox);
|
|
73
|
+
if (checkoutUrl) return checkoutUrl;
|
|
74
|
+
const received = data.url || data.checkout_url || (data.client_secret ? 'client_secret only' : 'none');
|
|
75
|
+
lastError = new Error(`Polar checkout response did not include a valid checkout URL (received: ${received})`);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
lastError = err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw lastError || new Error('Unable to create Polar checkout session');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function getPolarCheckoutUrl({ user, plan = 'pro_monthly', baseUrl, config }) {
|
|
85
|
+
const productId = resolvePlanProductId(plan, config);
|
|
86
|
+
|
|
87
|
+
if (config.accessToken && productId && !productId.startsWith('polar_cl_')) {
|
|
88
|
+
try {
|
|
89
|
+
return await createCheckoutSessionViaApi({ user, productId, baseUrl, config });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error('[polar] Checkout API error:', err.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hostedUrl = resolveHostedCheckoutBaseUrl(config);
|
|
96
|
+
if (hostedUrl && isValidCheckoutRedirectUrl(hostedUrl)) {
|
|
97
|
+
return buildHostedCheckoutUrl(hostedUrl, user, config);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(
|
|
101
|
+
'Polar checkout is not configured. Set POLAR_ACCESS_TOKEN + POLAR_PRO_PRODUCT_ID (product UUID), or POLAR_CHECKOUT_URL (https://buy.polar.sh/polar_cl_...).'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
resolvePlanProductId,
|
|
107
|
+
resolveHostedCheckoutBaseUrl,
|
|
108
|
+
buildHostedCheckoutUrl,
|
|
109
|
+
createCheckoutSessionViaApi,
|
|
110
|
+
getPolarCheckoutUrl,
|
|
111
|
+
};
|