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:
Marie
2026-06-18 20:42:07 +02:00
committed by GitHub
parent af36b8ab2d
commit 9de1b6330c
4 changed files with 106 additions and 5 deletions
+4
View File
@@ -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';
};