From 9eb90e72f9301133da555991eeabd0c6680365f7 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:56:00 +0500 Subject: [PATCH] [Website] Add internal enterprise key reissue endpoint. (#21807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of #21660 into the redone's enterprise backend. - Extract getLicenseeFromStripeCustomer into the platform/enterprise barrel, replacing the inline licensee-from-Stripe-customer block in the activate route; cover its branches with a unit test. - Add POST /api/enterprise/reissue: a support endpoint that re-signs an enterprise key from a subscriptionId, guarded by a timing-safe compare against ENTERPRISE_ADMIN_API_SECRET. Adapted to the redone — barrel imports, node:crypto named imports, and the sibling routes' 503 configured-check guard — while keeping the original's generic non-leaking 500. - Document ENTERPRISE_ADMIN_API_SECRET in .env.example. --- packages/twenty-website-redone/.env.example | 4 + .../src/app/api/enterprise/activate/route.ts | 12 +-- .../src/app/api/enterprise/reissue/route.ts | 98 +++++++++++++++++++ .../get-licensee-from-stripe-customer.test.ts | 48 +++++++++ .../get-licensee-from-stripe-customer.ts | 17 ++++ .../src/platform/enterprise/index.ts | 1 + 6 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-website-redone/src/app/api/enterprise/reissue/route.ts create mode 100644 packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.test.ts create mode 100644 packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.ts diff --git a/packages/twenty-website-redone/.env.example b/packages/twenty-website-redone/.env.example index 61d8adc22a..b3ac1c405d 100644 --- a/packages/twenty-website-redone/.env.example +++ b/packages/twenty-website-redone/.env.example @@ -25,3 +25,7 @@ ENTERPRISE_JWT_PUBLIC_KEY= # Optional: enterprise validity token lifetime in days (default 30). # ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS= + +# Shared secret guarding the internal enterprise key reissue endpoint +# (POST /api/enterprise/reissue), used to regenerate an enterprise key. +ENTERPRISE_ADMIN_API_SECRET= diff --git a/packages/twenty-website-redone/src/app/api/enterprise/activate/route.ts b/packages/twenty-website-redone/src/app/api/enterprise/activate/route.ts index 889ac35ef8..ef0a58d069 100644 --- a/packages/twenty-website-redone/src/app/api/enterprise/activate/route.ts +++ b/packages/twenty-website-redone/src/app/api/enterprise/activate/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server'; -import { getStripeClient, signEnterpriseKey } from '@/platform/enterprise'; +import { + getLicenseeFromStripeCustomer, + getStripeClient, + signEnterpriseKey, +} from '@/platform/enterprise'; export const dynamic = 'force-dynamic'; @@ -67,11 +71,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); diff --git a/packages/twenty-website-redone/src/app/api/enterprise/reissue/route.ts b/packages/twenty-website-redone/src/app/api/enterprise/reissue/route.ts new file mode 100644 index 0000000000..442baecbee --- /dev/null +++ b/packages/twenty-website-redone/src/app/api/enterprise/reissue/route.ts @@ -0,0 +1,98 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { NextResponse } from 'next/server'; + +import { + getLicenseeFromStripeCustomer, + getStripeClient, + signEnterpriseKey, +} from '@/platform/enterprise'; + +export const dynamic = 'force-dynamic'; + +function isSecretValid(providedSecret: string): boolean { + const expectedSecret = process.env.ENTERPRISE_ADMIN_API_SECRET; + + if (!expectedSecret || !providedSecret) { + return false; + } + + const comparisonKey = randomBytes(32); + const expectedDigest = createHmac('sha256', comparisonKey) + .update(expectedSecret) + .digest(); + const providedDigest = createHmac('sha256', comparisonKey) + .update(providedSecret) + .digest(); + + return timingSafeEqual(expectedDigest, providedDigest); +} + +export async function POST(request: Request) { + if ( + !process.env.STRIPE_SECRET_KEY || + !process.env.ENTERPRISE_JWT_PRIVATE_KEY || + !process.env.ENTERPRISE_ADMIN_API_SECRET + ) { + console.error( + '[enterprise-reissue] 503 — STRIPE_SECRET_KEY, ENTERPRISE_JWT_PRIVATE_KEY and/or ENTERPRISE_ADMIN_API_SECRET are not configured', + ); + return NextResponse.json( + { error: 'Enterprise key reissue is not configured.' }, + { status: 503 }, + ); + } + + 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 }, + ); + } +} diff --git a/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.test.ts b/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.test.ts new file mode 100644 index 0000000000..ea3c514d6e --- /dev/null +++ b/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.test.ts @@ -0,0 +1,48 @@ +import { getLicenseeFromStripeCustomer } from './get-licensee-from-stripe-customer'; + +type CustomerArg = Parameters[0]; + +const customer = (fields: Record): CustomerArg => + fields as unknown as CustomerArg; + +describe('getLicenseeFromStripeCustomer', () => { + it('should return the customer name when present', () => { + expect( + getLicenseeFromStripeCustomer( + customer({ deleted: false, email: 'ops@acme.com', name: 'Acme Inc' }), + ), + ).toBe('Acme Inc'); + }); + + it('should fall back to the email when the name is missing', () => { + expect( + getLicenseeFromStripeCustomer( + customer({ deleted: false, email: 'ops@acme.com', name: null }), + ), + ).toBe('ops@acme.com'); + }); + + it('should return Unknown when both name and email are missing', () => { + expect( + getLicenseeFromStripeCustomer( + customer({ deleted: false, email: null, name: null }), + ), + ).toBe('Unknown'); + }); + + it('should return Unknown for a deleted customer', () => { + expect( + getLicenseeFromStripeCustomer( + customer({ deleted: true, name: 'Acme Inc' }), + ), + ).toBe('Unknown'); + }); + + it('should return Unknown for an unexpanded customer id string', () => { + expect(getLicenseeFromStripeCustomer('cus_123')).toBe('Unknown'); + }); + + it('should return Unknown when there is no customer', () => { + expect(getLicenseeFromStripeCustomer(null)).toBe('Unknown'); + }); +}); diff --git a/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.ts b/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.ts new file mode 100644 index 0000000000..c1bd59b48f --- /dev/null +++ b/packages/twenty-website-redone/src/platform/enterprise/get-licensee-from-stripe-customer.ts @@ -0,0 +1,17 @@ +import type Stripe from 'stripe'; + +type StripeCustomerField = + | string + | Stripe.Customer + | Stripe.DeletedCustomer + | null; + +export function getLicenseeFromStripeCustomer( + customer: StripeCustomerField, +): string { + if (customer && typeof customer !== 'string' && !customer.deleted) { + return customer.name ?? customer.email ?? 'Unknown'; + } + + return 'Unknown'; +} diff --git a/packages/twenty-website-redone/src/platform/enterprise/index.ts b/packages/twenty-website-redone/src/platform/enterprise/index.ts index 8f33267948..6f4a13585d 100644 --- a/packages/twenty-website-redone/src/platform/enterprise/index.ts +++ b/packages/twenty-website-redone/src/platform/enterprise/index.ts @@ -1,4 +1,5 @@ export { getEnterprisePriceId } from './enterprise-price-id'; +export { getLicenseeFromStripeCustomer } from './get-licensee-from-stripe-customer'; export { getStripeClient } from './stripe-client'; export { getSubscriptionCurrentPeriodEnd } from './subscription-current-period-end'; export { signEnterpriseKey } from './sign-enterprise-key';