Add licensing api (#1723)

This commit is contained in:
Luke Vella 2025-05-23 15:35:40 +01:00 committed by GitHub
parent 982fc39ac7
commit 679d6fb034
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 607 additions and 40 deletions

View file

@ -0,0 +1,43 @@
"use server";
import { rateLimit } from "@/features/rate-limit";
import { prisma } from "@rallly/database";
import { licensingClient } from "../client";
export async function validateLicenseKey(key: string) {
const { success } = await rateLimit("validate_license_key", 10, "1 m");
if (!success) {
throw new Error("Rate limit exceeded");
}
const { data, error } = await licensingClient.validateLicenseKey({
key,
});
if (error) {
throw new Error(`License validation failed: ${error}`);
}
if (!data) {
return {
valid: false,
};
}
await prisma.instanceLicense.create({
data: {
licenseKey: data.key,
licenseeName: data.licenseeName,
licenseeEmail: data.licenseeEmail,
issuedAt: data.issuedAt,
expiresAt: data.expiresAt,
seats: data.seats,
type: data.type,
},
});
return {
valid: true,
};
}

View file

@ -0,0 +1,7 @@
import { env } from "@/env";
import { LicensingClient } from "./lib/licensing-client";
export const licensingClient = new LicensingClient({
apiUrl: env.LICENSE_API_URL,
authToken: env.LICENSE_API_AUTH_TOKEN,
});

View file

@ -0,0 +1,13 @@
/**
* Calculate a checksum for a string
* @param str The string to calculate the checksum for
* @returns The checksum
*/
export function calculateChecksum(str: string): string {
// Simple checksum: sum char codes, mod 100000, base36
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return (sum % 100000).toString(36).toUpperCase().padStart(5, "0");
}

View file

@ -0,0 +1,9 @@
import { calculateChecksum } from "./calculate-checksum";
export function checkLicenseKey(key: string): boolean {
const parts = key.split("-");
if (parts.length !== 6) return false;
const checksum = parts[5];
const licenseBody = parts.slice(0, 5).join("-");
return calculateChecksum(licenseBody) === checksum;
}

View file

@ -0,0 +1,21 @@
import { customAlphabet } from "nanoid";
import { calculateChecksum } from "./calculate-checksum";
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const generate = customAlphabet(alphabet, 4);
/**
* Generate user friendly licenses
* eg. RLYV4-ABCD-1234-ABCD-1234-XXXX
*/
export const generateLicenseKey = ({ version }: { version?: number }) => {
let license = `RLYV${version ?? "X"}-`;
for (let i = 0; i < 4; i++) {
license += generate();
if (i < 3) {
license += "-";
}
}
const checksum = calculateChecksum(license);
return `${license}-${checksum}`;
};

View file

@ -0,0 +1 @@
export { LicensingClient } from "./lib/licensing-client";

View file

@ -0,0 +1,59 @@
import {
type CreateLicenseInput,
type ValidateLicenseInputKeySchema,
createLicenseResponseSchema,
validateLicenseKeyResponseSchema,
} from "../schema";
export class LicensingClient {
apiUrl: string;
authToken?: string;
constructor({
apiUrl = "https://licensing.rallly.co",
authToken,
}: {
apiUrl?: string;
authToken?: string;
}) {
this.apiUrl = apiUrl;
this.authToken = authToken;
}
async createLicense(input: CreateLicenseInput) {
if (!this.authToken) {
throw new Error("Licensing API auth token is not configured.");
}
const res = await fetch(`${this.apiUrl}/api/v1/licenses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.authToken}`,
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error("Failed to create license.");
}
return createLicenseResponseSchema.parse(await res.json());
}
async validateLicenseKey(input: ValidateLicenseInputKeySchema) {
const res = await fetch(
`${this.apiUrl}/api/v1/licenses/actions/validate-key`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
},
);
if (!res.ok) {
throw new Error("Failed to validate license key.");
}
return validateLicenseKeyResponseSchema.parse(await res.json());
}
}

View file

@ -0,0 +1,88 @@
import { z } from "zod";
// =========================
// Enums & Basic Types
// =========================
export const licenseTypeSchema = z.enum(["PLUS", "ORGANIZATION", "ENTERPRISE"]);
export type LicenseType = z.infer<typeof licenseTypeSchema>;
export const licenseStatusSchema = z.enum(["ACTIVE", "REVOKED"]);
export type LicenseStatus = z.infer<typeof licenseStatusSchema>;
// =========================
// Generic API Response
// =========================
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: dataSchema.optional(),
error: z.string().optional(),
});
export type ApiResponse<T> = {
data?: T;
error?: string;
};
// =========================
// Create License
// =========================
export const createLicenseInputSchema = z.object({
type: licenseTypeSchema,
seats: z.number().optional(),
expiresAt: z.date().optional(),
licenseeEmail: z.string().optional(),
licenseeName: z.string().optional(),
version: z.number().optional(),
stripeCustomerId: z.string().optional(),
});
export type CreateLicenseInput = z.infer<typeof createLicenseInputSchema>;
export const createLicenseResponseSchema = apiResponseSchema(
z.object({
key: z.string(),
}),
);
export type CreateLicenseResponse = z.infer<typeof createLicenseResponseSchema>;
// =========================
// Validate License Key
// =========================
export const validateLicenseKeyInputSchema = z.object({
key: z.string(),
fingerprint: z.string().optional(),
});
export type ValidateLicenseInputKeySchema = z.infer<
typeof validateLicenseKeyInputSchema
>;
export const validateLicenseKeyResponseSchema = apiResponseSchema(
z.object({
key: z.string(),
valid: z.boolean(),
status: licenseStatusSchema,
issuedAt: z.date(),
expiresAt: z.date().nullable(),
licenseeEmail: z.string().nullable(),
licenseeName: z.string().nullable(),
seats: z.number().nullable(),
type: licenseTypeSchema,
version: z.number().nullable(),
}),
);
export type ValidateLicenseKeyResponse = z.infer<
typeof validateLicenseKeyResponseSchema
>;
export const licenseCheckoutMetadataSchema = z.object({
licenseType: licenseTypeSchema,
seats: z.number(),
});
export type LicenseCheckoutMetada = z.infer<
typeof licenseCheckoutMetadataSchema
>;