diff --git a/CHANGELOG.md b/CHANGELOG.md index 154cd1c..a5ffdf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [1.2.10] - 2026-06-01 -> Nightly releases — v1.3.10 • v1.3.11 • v1.3.15 +## [1.4.0] - 2026-06-02 +> Nightly releases — v1.3.10 • v1.3.11 • v1.3.15 • v1.5.0 ### Added diff --git a/api/create-subscription.js b/api/create-subscription.js index ecbc702..1ae7ac2 100644 --- a/api/create-subscription.js +++ b/api/create-subscription.js @@ -30,9 +30,14 @@ module.exports = async (req, res) => { key_secret, }); + // Razorpay requires total_count (number of billing cycles) when end_at is absent. + // Use a long horizon so it behaves like an ongoing subscription until cancelled. + const totalCount = period === 'annual' ? 10 : 120; // 10 years either way + try { const subscription = await razorpay.subscriptions.create({ plan_id: resolved.planId, + total_count: totalCount, customer_notify: 1, notes: { tier, diff --git a/api/lib/email.js b/api/lib/email.js new file mode 100644 index 0000000..0c998b1 --- /dev/null +++ b/api/lib/email.js @@ -0,0 +1,67 @@ +// Optional license-key email delivery via Resend. +// No-op (logs only) when RESEND_API_KEY is unset, so the webhook never fails +// just because email isn't configured. + +const https = require('https'); + +const FROM = process.env.LICENSE_EMAIL_FROM || 'PgStudio '; + +function activateUri(licenseKey) { + return `vscode://ric-v.postgres-explorer/activate?key=${encodeURIComponent(licenseKey)}`; +} + +function buildHtml(licenseKey, tier) { + const tierName = tier ? tier[0].toUpperCase() + tier.slice(1) : 'Pro'; + return ` +
+

Welcome to PgStudio ${tierName} 🎉

+

Your license key:

+

${licenseKey}

+

+ Activate in VS Code

+

Or run PgStudio: Activate License from the command palette and paste the key above.

+
`; +} + +function sendLicenseEmail(to, licenseKey, tier) { + return new Promise((resolve) => { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey || !to) { + console.log(`[email] skipped (no RESEND_API_KEY or recipient) for ${licenseKey}`); + return resolve({ sent: false }); + } + + const payload = JSON.stringify({ + from: FROM, + to: [to], + subject: 'Your PgStudio license key', + html: buildHtml(licenseKey, tier), + }); + + const req = https.request( + { + hostname: 'api.resend.com', + path: '/emails', + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => { + res.on('data', () => {}); + res.on('end', () => resolve({ sent: res.statusCode < 300 })); + }, + ); + req.on('error', (err) => { + console.error('[email] send failed:', err.message); + resolve({ sent: false }); + }); + req.write(payload); + req.end(); + }); +} + +module.exports = { sendLicenseEmail }; diff --git a/api/lib/license-key.js b/api/lib/license-key.js new file mode 100644 index 0000000..1632b30 --- /dev/null +++ b/api/lib/license-key.js @@ -0,0 +1,23 @@ +const crypto = require('crypto'); + +// License key format: PGST-XXXX-XXXX-XXXX-XXXX (Crockford-ish base32, no ambiguous chars). +const ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; // no I, L, O, 0, 1, U + +function group() { + const bytes = crypto.randomBytes(4); + let out = ''; + for (let i = 0; i < 4; i++) { + out += ALPHABET[bytes[i] % ALPHABET.length]; + } + return out; +} + +function generateLicenseKey() { + return `PGST-${group()}-${group()}-${group()}-${group()}`; +} + +function isWellFormed(key) { + return /^PGST-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(String(key || '')); +} + +module.exports = { generateLicenseKey, isWellFormed }; diff --git a/api/lib/store.js b/api/lib/store.js new file mode 100644 index 0000000..70f50db --- /dev/null +++ b/api/lib/store.js @@ -0,0 +1,107 @@ +// Entitlement store abstraction. +// +// Uses Vercel KV (Upstash Redis) when KV_REST_API_URL / KV_REST_API_TOKEN are +// present in the environment. Falls back to a local JSON file (.kv-dev.json at +// repo root) so the dev server and tests work without provisioning KV. +// +// Keys: +// ent: -> entitlement object (see Entitlement shape below) +// sub: -> licenseKey pointer (for success-page lookup) +// +// Entitlement shape: +// { +// licenseKey, tier, period, currency, +// status: 'active' | 'cancelled' | 'halted' | 'paused', +// subscriptionId, email, +// expiresAt, // unix ms, when the current paid window ends (null = open-ended) +// createdAt, // unix ms +// instanceIds: [] // bound VS Code machine ids (activation devices) +// } + +const path = require('path'); +const fs = require('fs'); + +const ENT_PREFIX = 'ent:'; +const SUB_PREFIX = 'sub:'; + +const useKv = Boolean(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN); + +let kvClient = null; +function kv() { + if (!kvClient) { + // Lazily required so deployments without KV configured never load it. + kvClient = require('@vercel/kv').kv; + } + return kvClient; +} + +// ---- Local file fallback (dev/test only) -------------------------------- + +const DEV_STORE_PATH = path.join(__dirname, '..', '..', '.kv-dev.json'); + +function readDevStore() { + try { + return JSON.parse(fs.readFileSync(DEV_STORE_PATH, 'utf8')); + } catch { + return {}; + } +} + +function writeDevStore(data) { + fs.writeFileSync(DEV_STORE_PATH, JSON.stringify(data, null, 2)); +} + +async function rawGet(key) { + if (useKv) { + return (await kv().get(key)) || null; + } + const store = readDevStore(); + return store[key] || null; +} + +async function rawSet(key, value) { + if (useKv) { + await kv().set(key, value); + return; + } + const store = readDevStore(); + store[key] = value; + writeDevStore(store); +} + +// ---- Public API ---------------------------------------------------------- + +async function getEntitlement(licenseKey) { + if (!licenseKey) return null; + return rawGet(ENT_PREFIX + licenseKey); +} + +async function putEntitlement(entitlement) { + if (!entitlement || !entitlement.licenseKey) { + throw new Error('putEntitlement requires a licenseKey'); + } + await rawSet(ENT_PREFIX + entitlement.licenseKey, entitlement); + if (entitlement.subscriptionId) { + await rawSet(SUB_PREFIX + entitlement.subscriptionId, entitlement.licenseKey); + } + return entitlement; +} + +async function getKeyBySubscription(subscriptionId) { + if (!subscriptionId) return null; + return rawGet(SUB_PREFIX + subscriptionId); +} + +async function getEntitlementBySubscription(subscriptionId) { + const licenseKey = await getKeyBySubscription(subscriptionId); + if (!licenseKey) return null; + return getEntitlement(licenseKey); +} + +module.exports = { + getEntitlement, + putEntitlement, + getKeyBySubscription, + getEntitlementBySubscription, + usingKv: useKv, +}; diff --git a/api/license/lookup.js b/api/license/lookup.js new file mode 100644 index 0000000..45f356f --- /dev/null +++ b/api/license/lookup.js @@ -0,0 +1,29 @@ +// GET /api/license/lookup?subscription_id=sub_xxx +// Returns: { licenseKey, tier } once the webhook has issued a key, else { pending: true }. +// +// Lets the post-checkout success page poll for the freshly issued key while the +// Razorpay webhook lands. subscription_id is unguessable, so no extra auth. + +const store = require('../lib/store'); + +module.exports = async (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + const subscriptionId = req.query && req.query.subscription_id; + if (!subscriptionId) { + return res.status(400).json({ error: 'subscription_id is required' }); + } + + try { + const ent = await store.getEntitlementBySubscription(subscriptionId); + if (!ent || !ent.licenseKey) { + return res.status(200).json({ pending: true }); + } + return res.status(200).json({ licenseKey: ent.licenseKey, tier: ent.tier }); + } catch (err) { + console.error('lookup: store error', err); + return res.status(500).json({ error: 'Store unavailable' }); + } +}; diff --git a/api/license/validate.js b/api/license/validate.js new file mode 100644 index 0000000..fdf8b0c --- /dev/null +++ b/api/license/validate.js @@ -0,0 +1,64 @@ +// POST /api/license/validate +// Body: { licenseKey, instanceId } +// Returns: { valid, tier, status, expiresAt } +// +// Called by the extension's LicenseService on activate and on background +// re-validation. Binds the VS Code machine id (instanceId) to the entitlement +// up to a device cap. + +const store = require('../lib/store'); + +const MAX_DEVICES = 10; + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + const { licenseKey, instanceId } = req.body || {}; + if (!licenseKey) { + return res.status(400).json({ error: 'licenseKey is required' }); + } + + let ent; + try { + ent = await store.getEntitlement(licenseKey); + } catch (err) { + console.error('validate: store error', err); + return res.status(500).json({ error: 'Store unavailable' }); + } + + if (!ent) { + return res.status(404).json({ valid: false, status: 'unknown' }); + } + + const active = ent.status === 'active' && (!ent.expiresAt || ent.expiresAt > Date.now()); + + if (active && instanceId) { + const ids = ent.instanceIds || []; + if (!ids.includes(instanceId)) { + if (ids.length >= MAX_DEVICES) { + return res.status(200).json({ + valid: false, + status: ent.status, + tier: ent.tier, + reason: 'device_limit', + }); + } + ids.push(instanceId); + ent.instanceIds = ids; + try { + await store.putEntitlement(ent); + } catch (err) { + console.error('validate: failed to bind instance', err); + } + } + } + + return res.status(200).json({ + valid: active, + tier: ent.tier, + status: ent.status, + expiresAt: ent.expiresAt || null, + }); +}; diff --git a/api/package-lock.json b/api/package-lock.json index ca1d219..906818e 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -5,9 +5,32 @@ "packages": { "": { "dependencies": { + "@vercel/kv": "^3.0.0", "razorpay": "^2.9.0" } }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vercel/kv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vercel/kv/-/kv-3.0.0.tgz", + "integrity": "sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==", + "deprecated": "Vercel KV is deprecated. If you had an existing KV store, it should have moved to Upstash Redis which you will see under Vercel Integrations. For new projects, install a Redis integration from Vercel Marketplace: https://vercel.com/marketplace?category=storage&search=redis", + "license": "Apache-2.0", + "dependencies": { + "@upstash/redis": "^1.34.0" + }, + "engines": { + "node": ">=14.6" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -347,6 +370,12 @@ "dependencies": { "axios": "^1.6.8" } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" } } } diff --git a/api/package.json b/api/package.json index f052526..131fac1 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,7 @@ { "private": true, "dependencies": { + "@vercel/kv": "^3.0.0", "razorpay": "^2.9.0" } } diff --git a/api/plan-config.js b/api/plan-config.js index ae14fef..b95631f 100644 --- a/api/plan-config.js +++ b/api/plan-config.js @@ -70,6 +70,23 @@ function buildTierCatalog() { return tiers; } +// Reverse map a Razorpay plan_id back to {tier, period, currency} by scanning +// RAZORPAY_PLAN_* env vars. Used by the webhook as a fallback when subscription +// notes are missing (notes set in create-subscription.js are the primary source). +function reversePlanLookup(planId) { + if (!planId) return null; + for (const tier of SUPPORTED_TIERS) { + for (const period of SUPPORTED_PERIODS) { + for (const currency of SUPPORTED_CURRENCIES) { + if (getPlanId(tier, period, currency) === planId) { + return { tier, period, currency }; + } + } + } + } + return null; +} + function resolvePlan(tier, period, currency) { if (!isValidTier(tier) || !isValidPeriod(period) || !isValidCurrency(currency)) { return { error: 'Invalid tier, period, or currency' }; @@ -89,6 +106,7 @@ module.exports = { SUPPORTED_PERIODS, buildTierCatalog, resolvePlan, + reversePlanLookup, isValidTier, isValidPeriod, isValidCurrency, diff --git a/api/webhook.js b/api/webhook.js new file mode 100644 index 0000000..45f2f71 --- /dev/null +++ b/api/webhook.js @@ -0,0 +1,158 @@ +// Razorpay subscription webhook — the authoritative source of entitlement. +// +// Verifies X-Razorpay-Signature against RAZORPAY_WEBHOOK_SECRET, then upserts an +// entitlement in the KV store and (first time) issues + emails a license key. +// +// Configure in Razorpay Dashboard → Settings → Webhooks with events: +// subscription.activated, subscription.charged, subscription.resumed, +// subscription.cancelled, subscription.halted, subscription.paused +// +// Raw body is required for signature verification, so body parsing is disabled. + +const crypto = require('crypto'); +const { reversePlanLookup } = require('./plan-config'); +const store = require('./lib/store'); +const { generateLicenseKey } = require('./lib/license-key'); +const { sendLicenseEmail } = require('./lib/email'); + +const ACTIVE_EVENTS = new Set([ + 'subscription.activated', + 'subscription.charged', + 'subscription.resumed', +]); +const STATUS_EVENTS = { + 'subscription.cancelled': 'cancelled', + 'subscription.halted': 'halted', + 'subscription.paused': 'paused', +}; + +const PERIOD_MS = { + monthly: 32 * 24 * 60 * 60 * 1000, // generous grace over 1 month + annual: 366 * 24 * 60 * 60 * 1000, +}; + +function getRawBody(req) { + if (Buffer.isBuffer(req.body)) return Promise.resolve(req.body); + if (typeof req.body === 'string') return Promise.resolve(Buffer.from(req.body)); + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function computeExpiry(period, subEntity) { + // Prefer Razorpay's authoritative current_end (unix seconds) when present. + if (subEntity && subEntity.current_end) { + return subEntity.current_end * 1000; + } + return Date.now() + (PERIOD_MS[period] || PERIOD_MS.monthly); +} + +module.exports = async (req, res) => { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + + const secret = process.env.RAZORPAY_WEBHOOK_SECRET; + if (!secret) { + console.error('RAZORPAY_WEBHOOK_SECRET missing from environment.'); + return res.status(500).json({ error: 'Webhook secret not configured' }); + } + + let raw; + try { + raw = await getRawBody(req); + } catch (err) { + return res.status(400).json({ error: 'Could not read request body' }); + } + + const signature = req.headers['x-razorpay-signature']; + const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex'); + const sigBuf = Buffer.from(String(signature || '')); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { + return res.status(400).json({ error: 'Invalid signature' }); + } + + let body; + try { + body = JSON.parse(raw.toString('utf8')); + } catch { + return res.status(400).json({ error: 'Invalid JSON' }); + } + + const event = body.event; + const subEntity = body.payload && body.payload.subscription && body.payload.subscription.entity; + + // Only subscription.* events carry an entitlement. + if (!subEntity || !subEntity.id) { + return res.status(200).json({ ignored: true, event }); + } + + const subscriptionId = subEntity.id; + + try { + const existing = await store.getEntitlementBySubscription(subscriptionId); + + if (ACTIVE_EVENTS.has(event)) { + const notes = subEntity.notes || {}; + const fromPlan = reversePlanLookup(subEntity.plan_id) || {}; + const tier = notes.tier || fromPlan.tier; + const period = notes.period || fromPlan.period || 'monthly'; + const currency = notes.currency || fromPlan.currency || 'INR'; + + if (!tier) { + console.error(`Cannot resolve tier for subscription ${subscriptionId} (plan ${subEntity.plan_id})`); + return res.status(200).json({ ignored: true, reason: 'unresolved tier' }); + } + + const email = + (body.payload.payment && body.payload.payment.entity && body.payload.payment.entity.email) || + notes.email || + (existing && existing.email) || + null; + + const licenseKey = (existing && existing.licenseKey) || generateLicenseKey(); + const isNew = !existing; + + const entitlement = { + licenseKey, + tier, + period, + currency, + status: 'active', + subscriptionId, + email, + expiresAt: computeExpiry(period, subEntity), + createdAt: existing ? existing.createdAt : Date.now(), + instanceIds: existing ? existing.instanceIds || [] : [], + }; + + await store.putEntitlement(entitlement); + + if (isNew && email) { + await sendLicenseEmail(email, licenseKey, tier); + } + + return res.status(200).json({ ok: true, event, licenseKey }); + } + + if (STATUS_EVENTS[event]) { + if (existing) { + existing.status = STATUS_EVENTS[event]; + await store.putEntitlement(existing); + } + return res.status(200).json({ ok: true, event, status: STATUS_EVENTS[event] }); + } + + return res.status(200).json({ ignored: true, event }); + } catch (err) { + console.error('Webhook processing error:', err); + return res.status(500).json({ error: 'Webhook processing failed' }); + } +}; + +// Vercel: preserve the raw body for signature verification. +module.exports.config = { api: { bodyParser: false } }; diff --git a/docs/RAZORPAY.md b/docs/RAZORPAY.md new file mode 100644 index 0000000..5d7e620 --- /dev/null +++ b/docs/RAZORPAY.md @@ -0,0 +1,285 @@ +# Razorpay setup & pricing (docs site) + +Last updated: 2026-06-02 + +The marketing site at `docs/` sells **Sponsor** and **Singularity** subscriptions via [Razorpay Subscriptions](https://razorpay.com/docs/payments/subscriptions/). Checkout is server-driven: Vercel serverless functions in `api/` create subscriptions; the browser opens Razorpay Checkout and verifies the payment signature. + +**Related:** [WEBSITE_CONTEXT.md](./WEBSITE_CONTEXT.md) (landing IA, deploy overview) · [roadmap/license-implementation.md](./roadmap/license-implementation.md) (extension license activation — not wired to Razorpay yet) + +--- + +## Pricing tiers + +| Tier | Key | Who it's for | Billing | +|------|-----|--------------|---------| +| **Free** | — | Everyone | $0 — install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer) | +| **Sponsor** | `sponsor` | Individual pro users | Razorpay subscription | +| **Singularity** | `singularity` | Teams / org (flat org license, not per seat) | Razorpay subscription | + +Feature copy on the landing page lives in `docs/html/minimized-overview.html` (pricing section). Paid tiers include everything below the previous tier plus the bullets listed there. + +--- + +## Canonical prices + +Amounts shown on cards come from `api/plan-config.js` defaults unless overridden with `RAZORPAY_DISPLAY_*` env vars. **Razorpay Plan billing amounts in the dashboard must match these** (minor units: paise for INR, cents for USD). + +| Tier | Period | INR | USD | ~Annual savings vs 12× monthly | +|------|--------|-----|-----|--------------------------------| +| Sponsor | Monthly | ₹199/mo | $2/mo | — | +| Sponsor | Annual | ₹1,990/yr | $20/yr | ~17% | +| Singularity | Monthly | ₹899/mo | $9/mo | — | +| Singularity | Annual | ₹8,990/yr | $90/yr | ~17% | + +**Razorpay dashboard amounts (create Plans with these charge amounts):** + +| Env key suffix | Billing interval | INR (₹) | USD ($) | +|----------------|------------------|---------|---------| +| `SPONSOR_MONTHLY_INR` | Every 1 month | 199 | — | +| `SPONSOR_ANNUAL_INR` | Every 1 year | 1,990 | — | +| `SPONSOR_MONTHLY_USD` | Every 1 month | — | 2.00 | +| `SPONSOR_ANNUAL_USD` | Every 1 year | — | 20.00 | +| `SINGULARITY_MONTHLY_INR` | Every 1 month | 899 | — | +| `SINGULARITY_ANNUAL_INR` | Every 1 year | 8,990 | — | +| `SINGULARITY_MONTHLY_USD` | Every 1 month | — | 9.00 | +| `SINGULARITY_ANNUAL_USD` | Every 1 year | — | 90.00 | + +The UI toggle “Save ~17%” on annual billing matches `(12 × monthly − annual) / (12 × monthly)` for both tiers and currencies. + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant Browser as docs (pricing.js + checkout.js) + participant API as Vercel api/* + participant RZP as Razorpay + + Browser->>API: GET /api/config + API-->>Browser: key_id, tiers (display + available) + Note over Browser: User picks INR/USD + monthly/annual + Browser->>API: POST /api/create-subscription {tier, period, currency} + API->>RZP: subscriptions.create(plan_id) + RZP-->>API: subscription_id + API-->>Browser: subscription_id, display + Browser->>RZP: Checkout (subscription_id) + RZP-->>Browser: payment success + signature fields + Browser->>API: POST /api/verify-payment (HMAC verify) + API-->>Browser: { success: true } +``` + +| Piece | Location | Role | +|-------|----------|------| +| Price labels & toggles | `docs/js/pricing.js` | INR/USD + monthly/annual; loads catalog from `/api/config` | +| Checkout | `docs/js/checkout.js` | Creates subscription, opens Razorpay, verifies signature | +| Plan resolution | `api/plan-config.js` | Env → plan IDs, display strings, `available` flag | +| Public config | `api/config.js` | `GET` — `key_id` + tier catalog (no secret) | +| Create subscription | `api/create-subscription.js` | `POST` — `{ tier, period, currency }` | +| Verify payment | `api/verify-payment.js` | `POST` — HMAC-SHA256 over `order_id\|payment_id` | +| Local dev server | `scripts/dev-server.js` | Serves `docs/` + mounts same API routes | +| Env template | `.env.example` | All `RAZORPAY_*` keys | + +Checkout buttons: `data-tier="sponsor"` / `data-tier="singularity"` on cards with `data-pricing-tier` for live price updates. Pay buttons stay **disabled** until the matching `RAZORPAY_PLAN_*` env var is set to a real `plan_…` id (`available: false` in catalog). + +--- + +## Environment variables + +Copy from repo root: + +```bash +cp .env.example .env +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `RAZORPAY_KEY_ID` | Yes | Dashboard → API Keys → Key ID (`rzp_test_…` or `rzp_live_…`) | +| `RAZORPAY_KEY_SECRET` | Yes | Secret for server-side API + signature verification (never expose to browser) | +| `RAZORPAY_PLAN_{TIER}_{PERIOD}_{CURRENCY}` | Per plan | Eight plan IDs — see table below | +| `RAZORPAY_DISPLAY_{TIER}_{PERIOD}_{CURRENCY}` | No | Override card label (e.g. `₹199/mo`); defaults in `plan-config.js` | + +**Tier / period / currency** in env names are uppercase: `SPONSOR`, `SINGULARITY` × `MONTHLY`, `ANNUAL` × `INR`, `USD`. + +``` +RAZORPAY_PLAN_SPONSOR_MONTHLY_INR +RAZORPAY_PLAN_SPONSOR_ANNUAL_INR +RAZORPAY_PLAN_SPONSOR_MONTHLY_USD +RAZORPAY_PLAN_SPONSOR_ANNUAL_USD +RAZORPAY_PLAN_SINGULARITY_MONTHLY_INR +RAZORPAY_PLAN_SINGULARITY_ANNUAL_INR +RAZORPAY_PLAN_SINGULARITY_MONTHLY_USD +RAZORPAY_PLAN_SINGULARITY_ANNUAL_USD +``` + +**Migration:** Older deployments used `RAZORPAY_PLAN_STUDIO_*` / `RAZORPAY_PLAN_TEAM_*`. Those keys are ignored; recreate eight Plans under Sponsor/Singularity naming and update Vercel env. + +Never commit `.env`. Set the same variables in **Vercel → Project → Settings → Environment Variables** for Production (and Preview if you test PRs). + +--- + +## Razorpay Dashboard setup + +Do this in **Test Mode** first, then duplicate Plans in Live Mode with live API keys. + +### 1. API keys + +1. [Razorpay Dashboard](https://dashboard.razorpay.com/) → **Settings → API Keys**. +2. Generate **Test** keys → put `key_id` / `key_secret` in `.env` and Vercel. +3. After end-to-end test, repeat for **Live** keys and live Plans. + +### 2. Enable subscriptions + +1. **Subscriptions** must be enabled on the account (Razorpay may require activation — follow their onboarding). +2. For **USD** Plans, enable **international payments** (Settings → Payment methods / international, per Razorpay’s current UI). + +### 3. Create eight Plans + +**Subscriptions → Plans → Create Plan** (repeat eight times). + +Use a consistent naming scheme, e.g. `PgStudio Sponsor Monthly INR`. + +| Plan purpose | Interval | Currency | Amount | +|--------------|----------|----------|--------| +| Sponsor monthly | Monthly | INR | ₹199 | +| Sponsor annual | Yearly | INR | ₹1,990 | +| Sponsor monthly | Monthly | USD | $2 | +| Sponsor annual | Yearly | USD | $20 | +| Singularity monthly | Monthly | INR | ₹899 | +| Singularity annual | Yearly | INR | ₹8,990 | +| Singularity monthly | Monthly | USD | $9 | +| Singularity annual | Yearly | USD | $90 | + +Copy each Plan’s id (`plan_xxxxxxxxxxxx`) into the matching `RAZORPAY_PLAN_*` env var. + +Optional: set **Plan notes** or description to `tier=sponsor period=monthly currency=INR` for support debugging (checkout also sends `notes` on the subscription create call). + +### 4. Webhooks (recommended — not implemented in repo yet) + +For production you should add a webhook endpoint for `subscription.activated`, `subscription.charged`, `subscription.cancelled`, and payment failures, then issue extension licenses from those events. Today, success UI only runs after **client-side** signature verification in `verify-payment.js`; there is no server-side entitlement store. See [license-implementation.md](./roadmap/license-implementation.md). + +--- + +## Local development + +```bash +# From repo root +cp .env.example .env +# Fill RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, and all eight RAZORPAY_PLAN_* values + +cd api && npm install && cd .. +npm install # express for dev-server (root package.json) + +npm run dev:site +# → http://localhost:3000 +``` + +1. Open the site, scroll to **Pricing**. +2. Toggle INR/USD and monthly/annual — prices should update from `/api/config`. +3. **Get Sponsor** / **Get Singularity** should open Razorpay Checkout when `available` is true. +4. Use Razorpay [test cards](https://razorpay.com/docs/payments/payments/test-card-details/) (INR; use international test flow for USD if enabled). + +If pay buttons stay disabled, check browser console for catalog errors and confirm plan env vars are not empty or `plan_` placeholder. + +--- + +## Vercel deployment + +`vercel.json`: + +- `outputDirectory`: `docs` (static site) +- `api/*.js`: serverless functions (`config`, `create-subscription`, `verify-payment`) + +Deploy steps: + +1. Connect the Git repo to Vercel. +2. Set all `RAZORPAY_*` env vars for **Production** (test keys only on Preview if desired). +3. Deploy; confirm `GET https:///api/config` returns `key_id` and `tiers.*.*.available: true` for configured plans. + +Custom domain: `docs/CNAME` if used with your DNS provider. + +--- + +## API reference (docs checkout) + +### `GET /api/config` + +Response: + +```json +{ + "key_id": "rzp_test_…", + "supported_currencies": ["INR", "USD"], + "tiers": { + "sponsor": { + "name": "Sponsor", + "monthly": { + "INR": { "display": "₹199/mo", "available": true }, + "USD": { "display": "$2/mo", "available": true } + }, + "annual": { "…": "…" } + }, + "singularity": { "…": "…" } + } +} +``` + +### `POST /api/create-subscription` + +Body: `{ "tier": "sponsor"|"singularity", "period": "monthly"|"annual", "currency": "INR"|"USD" }` + +Success: `{ "subscription_id", "key_id", "tier", "period", "currency", "display" }` + +Errors: `400` invalid tier/period/currency or missing plan config; `401` bad Razorpay credentials; `500` Razorpay API failure. + +### `POST /api/verify-payment` + +Body: `{ "razorpay_order_id", "razorpay_payment_id", "razorpay_signature" }` (from Checkout `handler`). + +Success: `{ "success": true }`. Signature mismatch → `400` (do not treat as paid). + +--- + +## Currency detection (UI) + +`pricing.js` defaults: + +- **INR** if timezone is `Asia/Kolkata`, or `navigator.language` is `en-in` / `hi-in` +- **USD** otherwise + +User overrides are stored in `sessionStorage` (`pgstudio_pricing_currency`, `pgstudio_pricing_period`). + +--- + +## Checklist before go-live + +- [ ] Eight Plans created in **Live** mode with amounts matching the price table +- [ ] Live `RAZORPAY_KEY_ID` / `RAZORPAY_KEY_SECRET` on Vercel Production +- [ ] All eight `RAZORPAY_PLAN_*` set to live `plan_…` ids +- [ ] International payments enabled for USD Plans +- [ ] Test checkout on Production domain (small real charge or Razorpay live test procedure) +- [ ] Plan webhook + license delivery (follow-up — not in current codebase) + +--- + +## Troubleshooting + +| Symptom | Likely cause | +|---------|----------------| +| Pay button disabled, tooltip “plan not configured” | Missing or placeholder `RAZORPAY_PLAN_*` for current tier/period/currency | +| `Plan not configured for …` on checkout | Same — or typo in env name (must match `plan-config.js` keys exactly) | +| `Authentication failed with Razorpay API` | Wrong secret, test key with live plan (or vice versa) | +| Prices show `—` | `/api/config` failed — run via `dev:site` or Vercel, not raw `file://` | +| Checkout works locally but not on Vercel | Env vars not set for the deployment environment you’re hitting | +| Payment succeeds but extension still free | Expected until license service + webhooks are implemented | + +--- + +## Files to touch when prices change + +1. Amounts in **Razorpay Dashboard** (edit or create new Plans — changing amount often requires a new Plan id). +2. `api/plan-config.js` → `DEFAULT_DISPLAY` (and optional `.env.example` comments). +3. Redeploy Vercel; update `RAZORPAY_PLAN_*` if Plan ids changed. +4. Optional: `RAZORPAY_DISPLAY_*` overrides without code deploy. + +Landing HTML (`minimized-overview.html`) shows static fallback amounts in markup; live values are overwritten by `pricing.js` after `/api/config` loads. diff --git a/docs/WEBSITE_CONTEXT.md b/docs/WEBSITE_CONTEXT.md index 6c00cd8..b4aaa97 100644 --- a/docs/WEBSITE_CONTEXT.md +++ b/docs/WEBSITE_CONTEXT.md @@ -1,6 +1,6 @@ # Docs Website Context -Last updated: 2026-05-31 +Last updated: 2026-06-02 Primary entry: docs/index.html Hosting: Vercel (migrated from GitHub Pages) Design reference: nexql.html (palette/copy only — **not** the inline IDE demo) @@ -58,7 +58,18 @@ Top nav: **Features · AI · Compare · FAQ · Pricing · GitHub · Install — | Sponsor | `sponsor` | Individual pro | Razorpay subscription | | Singularity | `singularity` | Teams / org | Razorpay subscription, flat org license | -Pricing UI: monthly/annual + INR/USD toggles (`docs/js/pricing.js`); checkout uses `data-tier="sponsor"` / `"singularity"`. +**Full Razorpay setup, price matrix, dashboard steps, API, and troubleshooting:** [RAZORPAY.md](./RAZORPAY.md) + +### Price matrix (defaults) + +| Tier | Monthly INR | Annual INR | Monthly USD | Annual USD | +|------|-------------|------------|-------------|------------| +| Sponsor | ₹199 | ₹1,990 (~17% off) | $2 | $20 | +| Singularity | ₹899 | ₹8,990 | $9 | $90 | + +Source of truth: `api/plan-config.js` (`DEFAULT_DISPLAY`); overridable via `RAZORPAY_DISPLAY_*`. Razorpay Plan **billing amounts** must match these figures. + +Pricing UI: monthly/annual + INR/USD toggles (`docs/js/pricing.js`); checkout uses `data-tier="sponsor"` / `"singularity"`. Pay buttons enable only when the matching `RAZORPAY_PLAN_*` env var is a real `plan_…` id. ## Runtime behavior @@ -83,29 +94,19 @@ Aggregator: `docs/styles.css` - API: `api/config`, `api/create-subscription`, `api/verify-payment` - Plan config: `api/plan-config.js` -Environment (see `.env.example`): -- `RAZORPAY_KEY_ID`, `RAZORPAY_KEY_SECRET` -- `RAZORPAY_PLAN_{SPONSOR|SINGULARITY}_{MONTHLY|ANNUAL}_{INR|USD}` — eight plan IDs -- Optional `RAZORPAY_DISPLAY_*` overrides +Razorpay credentials, eight Plan IDs, local dev, dashboard checklist, and API details: **[RAZORPAY.md](./RAZORPAY.md)** (canonical). -Local dev: +Quick local dev: ```bash -cp .env.example .env -cd api && npm install -npm run dev:site # http://localhost:3000 +cp .env.example .env # fill RAZORPAY_KEY_* and all RAZORPAY_PLAN_* +cd api && npm install && cd .. +npm run dev:site # http://localhost:3000 ``` -**Post-rebrand deploy:** Recreate or rename Razorpay Plans and update Vercel env vars. Old `RAZORPAY_PLAN_STUDIO_*` / `TEAM_*` keys will not resolve until migrated. - -## Razorpay dashboard (manual) - -1. Create **8 Plans**: Sponsor + Singularity × monthly/annual × INR/USD. -2. Paste `plan_...` IDs into env. -3. Enable international payments for USD plans. -4. Test mode cards for INR/USD. +**Post-rebrand:** Use `RAZORPAY_PLAN_SPONSOR_*` / `SINGULARITY_*` only; old `STUDIO_*` / `TEAM_*` env keys are obsolete. -License activation / webhooks remain follow-up (`docs/roadmap/license-implementation.md`). +Extension license delivery after payment is **not** implemented yet — see `docs/roadmap/license-implementation.md`. ## Maintenance rules diff --git a/docs/html/minimized-overview.html b/docs/html/minimized-overview.html index 839f5f8..e830ae3 100644 --- a/docs/html/minimized-overview.html +++ b/docs/html/minimized-overview.html @@ -417,6 +417,10 @@

Simple, transparent pricingGet Singularity +

+ After checkout you’ll get a license key — click Activate in VS Code or run + PgStudio: Activate License from the command palette. Your key is also emailed to you. +

diff --git a/docs/js/checkout.js b/docs/js/checkout.js index 95cb085..5c04360 100644 --- a/docs/js/checkout.js +++ b/docs/js/checkout.js @@ -53,6 +53,42 @@ display: inline-block; } @keyframes spin-anim { to { transform: rotate(360deg); } } + + .license-modal-overlay { + position: fixed; inset: 0; z-index: 10001; + background: rgba(8, 8, 16, 0.72); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 0.3s ease; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + } + .license-modal-overlay.show { opacity: 1; } + .license-modal { + background: #161625; border: 1px solid rgba(255,255,255,0.1); + border-radius: 16px; padding: 32px; max-width: 440px; width: calc(100% - 32px); + color: #f8f8f2; box-shadow: 0 24px 64px rgba(0,0,0,0.5); + transform: translateY(16px) scale(0.98); transition: transform 0.3s cubic-bezier(0.175,0.885,0.32,1.275); + } + .license-modal-overlay.show .license-modal { transform: translateY(0) scale(1); } + .license-modal h3 { margin: 0 0 8px; font-size: 20px; } + .license-modal p { margin: 0 0 16px; color: #b8b8c8; font-size: 14px; line-height: 1.5; } + .license-key-row { + display: flex; gap: 8px; margin-bottom: 20px; + } + .license-key-value { + flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; padding: 12px 14px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 15px; letter-spacing: 1px; color: #fff; user-select: all; + } + .license-btn { + border: none; border-radius: 8px; padding: 12px 18px; font-weight: 600; cursor: pointer; + font-size: 14px; font-family: inherit; transition: opacity 0.2s ease, background 0.2s ease; + } + .license-btn:hover { opacity: 0.9; } + .license-btn-copy { background: rgba(255,255,255,0.1); color: #fff; } + .license-btn-primary { background: #6C4CF0; color: #fff; width: 100%; text-align: center; text-decoration: none; display: block; box-sizing: border-box; margin-bottom: 10px; } + .license-btn-secondary { background: transparent; color: #9ca3af; width: 100%; } + .license-pending { display: flex; align-items: center; gap: 10px; color: #b8b8c8; font-size: 14px; } `; document.head.appendChild(style); @@ -86,6 +122,81 @@ }); } + const ACTIVATE_URI_BASE = 'vscode://ric-v.postgres-explorer/activate?key='; + + // Poll the lookup endpoint until the webhook has issued a license key. + async function pollLicenseKey(subscriptionId, attempts = 6) { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(`/api/license/lookup?subscription_id=${encodeURIComponent(subscriptionId)}`); + if (res.ok) { + const data = await res.json(); + if (data.licenseKey) return data.licenseKey; + } + } catch (err) { + // network hiccup — keep polling + } + await new Promise((r) => setTimeout(r, 1500 + i * 750)); // backoff + } + return null; // webhook not landed yet — fall back to email messaging + } + + function showLicenseModal(tierLabel, licenseKey) { + const overlay = document.createElement('div'); + overlay.className = 'license-modal-overlay'; + + const close = () => { + overlay.classList.remove('show'); + setTimeout(() => overlay.remove(), 300); + }; + + const keyBlock = licenseKey + ? ` +

Your license key is ready. Activate PgStudio in VS Code:

+
+
${licenseKey}
+ +
+ Activate in VS Code +

Or run PgStudio: Activate License in the command palette and paste the key. A copy was also emailed to you.

+ ` + : ` +
Issuing your license key…
+

Your key is being generated and will arrive by email shortly. You can also find it later from your subscription receipt.

+ `; + + overlay.innerHTML = ` + `; + + document.body.appendChild(overlay); + setTimeout(() => overlay.classList.add('show'), 50); + + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelector('#lic-close').addEventListener('click', close); + + const copyBtn = overlay.querySelector('#lic-copy'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(licenseKey); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000); + } catch { + const el = overlay.querySelector('#lic-key'); + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + }); + } + } + async function fetchConfig() { if (configCache) return configCache; const res = await fetch('/api/config'); @@ -177,10 +288,13 @@ const verifyData = await verifyRes.json(); if (verifyRes.ok && verifyData.success) { - showCheckoutAlert( - 'success', - `Welcome to PgStudio ${tierLabel}!
Your ${displayPrice} subscription payment was verified.` - ); + resetButton(); + showLicenseModal(tierLabel, null); // optimistic: modal opens immediately + const licenseKey = await pollLicenseKey(subData.subscription_id); + const open = document.querySelector('.license-modal-overlay'); + if (open) open.remove(); // replace pending modal with final state + showLicenseModal(tierLabel, licenseKey); + return; } else { showCheckoutAlert( 'error', diff --git a/package-lock.json b/package-lock.json index 9efbcf0..78c130c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "postgres-explorer", - "version": "1.3.10", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postgres-explorer", - "version": "1.3.10", + "version": "1.4.1", "license": "MIT", "dependencies": { "@cursor/sdk": "^1.0.12", @@ -34,7 +34,7 @@ "@types/pg": "^8.20.0", "@types/sinon": "^21.0.1", "@types/ssh2": "^1.15.5", - "@types/vscode": "^1.107.0", + "@types/vscode": "1.105.0", "@types/vscode-notebook-renderer": "^1.72.4", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", @@ -58,7 +58,7 @@ }, "engines": { "node": ">=18.0.0", - "vscode": "^1.107.0" + "vscode": "^1.105.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -2806,9 +2806,9 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.118.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.118.0.tgz", - "integrity": "sha512-Ah6eTlqDcwIMELEVwQMO++rJAFBRz/oLluLD/vWdYrH1KuI9kfpaM+7pg0OvvascgcJy+ghLCERAYouM4QbzGw==", + "version": "1.105.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.105.0.tgz", + "integrity": "sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==", "dev": true, "license": "MIT" }, @@ -9248,7 +9248,6 @@ "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 4370495..56956b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.4.0", + "version": "1.4.1", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, @@ -20,7 +20,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.107.0", + "vscode": "^1.105.0", "node": ">=18.0.0" }, "categories": [ @@ -3458,7 +3458,7 @@ "@types/pg": "^8.20.0", "@types/sinon": "^21.0.1", "@types/ssh2": "^1.15.5", - "@types/vscode": "^1.107.0", + "@types/vscode": "1.105.0", "@types/vscode-notebook-renderer": "^1.72.4", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", @@ -3491,4 +3491,4 @@ "webpack": "^5.76.0", "webpack-cli": "^5.0.0" } -} \ No newline at end of file +} diff --git a/scripts/dev-server.js b/scripts/dev-server.js index fe68ea5..29d60ee 100644 --- a/scripts/dev-server.js +++ b/scripts/dev-server.js @@ -2,9 +2,11 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); -// Simple .env parser to load environment variables locally -const envPath = path.join(__dirname, '../.env'); -if (fs.existsSync(envPath)) { +// Simple .env parser to load environment variables locally. +// Load order matches Vercel: `.env` then `.env.local` (local overrides), from repo +// root then api/ (api-scoped values override root for parity with the functions). +function loadEnvFile(envPath) { + if (!fs.existsSync(envPath)) return false; try { const envContent = fs.readFileSync(envPath, 'utf8'); envContent.split(/\r?\n/).forEach(line => { @@ -14,33 +16,38 @@ if (fs.existsSync(envPath)) { if (index > 0) { const key = trimmed.substring(0, index).trim(); const value = trimmed.substring(index + 1).trim(); - // Remove surrounding quotes if any const cleanValue = value.replace(/^['"]|['"]$/g, ''); process.env[key] = cleanValue; } }); - console.log('Successfully loaded credentials from .env'); + console.log(`Loaded env from ${path.relative(path.join(__dirname, '..'), envPath)}`); + return true; } catch (error) { - console.error('Error loading .env file:', error); + console.error(`Error loading ${envPath}:`, error); + return false; } -} else { - console.warn('.env file not found at project root. Using fallback/existing environment variables.'); +} + +// Root only, matching Vercel (which does not read api/.env*). `.env.local` overrides `.env`. +const envCandidates = [ + path.join(__dirname, '../.env'), + path.join(__dirname, '../.env.local'), +]; +const loadedAny = envCandidates.map(loadEnvFile).some(Boolean); +if (!loadedAny) { + console.warn('No .env / .env.local found. Using fallback/existing environment variables.'); } const app = express(); const PORT = process.env.PORT || 3000; -// Body parsing middlewares -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Serve docs/ statically -app.use(express.static(path.join(__dirname, '../docs'))); - // Import Serverless function modules const configHandler = require('../api/config'); const createSubscriptionHandler = require('../api/create-subscription'); const verifyPaymentHandler = require('../api/verify-payment'); +const webhookHandler = require('../api/webhook'); +const licenseValidateHandler = require('../api/license/validate'); +const licenseLookupHandler = require('../api/license/lookup'); // Standard Express wrapper for Serverless function signature (req, res) const wrapServerless = (handler) => { @@ -53,10 +60,23 @@ const wrapServerless = (handler) => { }; }; +// Webhook needs the RAW body for signature verification — register it before +// the JSON body parser so express.json() doesn't consume the stream. +app.post('/api/webhook', express.raw({ type: '*/*' }), wrapServerless(webhookHandler)); + +// Body parsing middlewares (apply to all other routes) +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Serve docs/ statically +app.use(express.static(path.join(__dirname, '../docs'))); + // API Endpoints app.get('/api/config', wrapServerless(configHandler)); app.post('/api/create-subscription', wrapServerless(createSubscriptionHandler)); app.post('/api/verify-payment', wrapServerless(verifyPaymentHandler)); +app.post('/api/license/validate', wrapServerless(licenseValidateHandler)); +app.get('/api/license/lookup', wrapServerless(licenseLookupHandler)); // Global Error Handler app.use((err, req, res, next) => { diff --git a/vercel.json b/vercel.json index 4ceb8cc..2e3767d 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,7 @@ { "outputDirectory": "docs", "functions": { - "api/*.js": { + "api/**/*.js": { "memory": 128, "maxDuration": 10 }