Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions api/create-subscription.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions api/lib/email.js
Original file line number Diff line number Diff line change
@@ -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 <licenses@pgstudio.dev>';

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 `
<div style="font-family:Inter,Arial,sans-serif;max-width:520px;margin:auto">
<h2>Welcome to PgStudio ${tierName} 🎉</h2>
<p>Your license key:</p>
<p style="font-size:18px;font-weight:700;letter-spacing:1px;background:#f4f4f8;padding:12px 16px;border-radius:8px">${licenseKey}</p>
<p><a href="${activateUri(licenseKey)}"
style="display:inline-block;background:#6C4CF0;color:#fff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600">
Activate in VS Code</a></p>
<p style="color:#666;font-size:13px">Or run <b>PgStudio: Activate License</b> from the command palette and paste the key above.</p>
</div>`;
}

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 };
23 changes: 23 additions & 0 deletions api/lib/license-key.js
Original file line number Diff line number Diff line change
@@ -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 };
107 changes: 107 additions & 0 deletions api/lib/store.js
Original file line number Diff line number Diff line change
@@ -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:<licenseKey> -> entitlement object (see Entitlement shape below)
// sub:<subscriptionId> -> 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,
};
29 changes: 29 additions & 0 deletions api/license/lookup.js
Original file line number Diff line number Diff line change
@@ -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' });
}
};
64 changes: 64 additions & 0 deletions api/license/validate.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
29 changes: 29 additions & 0 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"private": true,
"dependencies": {
"@vercel/kv": "^3.0.0",
"razorpay": "^2.9.0"
}
}
Loading
Loading