feat(website - self hosts billing): add internal endpoint to reissue enterprise keys (#21660)
## Summary Adds an internal support endpoint to regenerate a customer's enterprise key when they've lost the one issued at activation. The key payload is deterministic from the Stripe subscription, so this re-emits an equivalent valid key without any new state. `GET /api/enterprise/reissue/<subscriptionId>/<secret>` - Guarded by a shared secret (`ENTERPRISE_ADMIN_API_SECRET`), compared in constant time and fail-closed when unset. - Looks up the subscription in Stripe (for the licensee) and signs the key with `signEnterpriseKey()`, reading `ENTERPRISE_JWT_PRIVATE_KEY` from the environment — the private key is never accepted from the request. - No subscription-status gate: the key alone grants nothing. Feature access still requires a validity token, which `/api/enterprise/validate` only issues after re-checking the subscription is active. ## Notes / follow-ups - The admin secret travels in the URL path, so it can land in server/proxy/CDN access logs — rotate `ENTERPRISE_ADMIN_API_SECRET` if logs are ever exposed. - No audit logging yet; worth adding (who reissued which subscription, when). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21660?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,6 +36,10 @@ ENTERPRISE_JWT_PUBLIC_KEY=
|
||||
# Optional: short-lived validity token length in days (default 30)
|
||||
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=
|
||||
|
||||
# Shared secret guarding the internal enterprise key reissue support endpoint,
|
||||
# used to regenerate an enterprise key
|
||||
ENTERPRISE_ADMIN_API_SECRET=
|
||||
|
||||
# Twenty workspace the partners marketplace reads partner data from
|
||||
# (server-side only) via the /s/partners REST endpoint.
|
||||
TWENTY_PARTNERS_API_URL=
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { signEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
|
||||
import { getLicenseeFromStripeCustomer } from '@/lib/enterprise/stripe-customer-helpers';
|
||||
import { getStripeClient } from '@/lib/enterprise/stripe-client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
@@ -59,11 +60,7 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const customer = session.customer;
|
||||
const licensee =
|
||||
customer && typeof customer !== 'string' && !customer.deleted
|
||||
? (customer.name ?? customer.email ?? 'Unknown')
|
||||
: 'Unknown';
|
||||
const licensee = getLicenseeFromStripeCustomer(session.customer);
|
||||
|
||||
const enterpriseKey = signEnterpriseKey(subscription.id, licensee);
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { signEnterpriseKey } from '@/lib/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/lib/enterprise/stripe-client';
|
||||
import { getLicenseeFromStripeCustomer } from '@/lib/enterprise/stripe-customer-helpers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const isSecretValid = (providedSecret: string): boolean => {
|
||||
const expectedSecret = process.env.ENTERPRISE_ADMIN_API_SECRET;
|
||||
|
||||
if (!expectedSecret || !providedSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const comparisonKey = crypto.randomBytes(32);
|
||||
const expectedDigest = crypto
|
||||
.createHmac('sha256', comparisonKey)
|
||||
.update(expectedSecret)
|
||||
.digest();
|
||||
const providedDigest = crypto
|
||||
.createHmac('sha256', comparisonKey)
|
||||
.update(providedSecret)
|
||||
.digest();
|
||||
|
||||
return crypto.timingSafeEqual(expectedDigest, providedDigest);
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
let body: { subscriptionId?: unknown; secret?: unknown } = {};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const secret = typeof body.secret === 'string' ? body.secret : '';
|
||||
|
||||
if (!isSecretValid(secret)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof body.subscriptionId === 'string' ? body.subscriptionId : '';
|
||||
|
||||
if (!subscriptionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing subscriptionId' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['customer'],
|
||||
});
|
||||
|
||||
const licensee = getLicenseeFromStripeCustomer(subscription.customer);
|
||||
const enterpriseKey = signEnterpriseKey(subscription.id, licensee);
|
||||
|
||||
const response = NextResponse.json({
|
||||
enterpriseKey,
|
||||
licensee,
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionStatus: subscription.status,
|
||||
});
|
||||
|
||||
response.headers.set('Cache-Control', 'no-store');
|
||||
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
console.error('Enterprise key reissue failed', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
type StripeCustomerField =
|
||||
| string
|
||||
| Stripe.Customer
|
||||
| Stripe.DeletedCustomer
|
||||
| null;
|
||||
|
||||
export const getLicenseeFromStripeCustomer = (
|
||||
customer: StripeCustomerField,
|
||||
): string => {
|
||||
if (customer && typeof customer !== 'string' && !customer.deleted) {
|
||||
return customer.name ?? customer.email ?? 'Unknown';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
Reference in New Issue
Block a user