Langages
Intégration TypeScript
SDK @keyline/sdk : fetch + WebCrypto pour Node 20+, navigateurs et Electron.
SDK officiel
Paquet @keyline/sdk (dossier packages/sdk-ts du dépôt), ESM, aucune dépendance. @keyline/sdk/node fournit l’identifiant d’appareil pour Node et Electron.
typescript
import { LicenseClient } from '@keyline/sdk';
import { defaultDeviceId } from '@keyline/sdk/node'; // Node / Electron
const client = new LicenseClient({
apiUrl: 'https://keyline.example.com/api/v1',
applicationId: '<application_id>',
publicKeys: { '<kid>': PUBLIC_KEY_PEM },
appVersion: '1.4.2',
});
const result = await client.activate({ licenseKey, deviceId: defaultDeviceId('mon-logiciel') });
if (result.success) {
const stop = client.startHeartbeat({ onFailure: (r) => lockPremiumFeatures(r.error.code) });
} else if (result.error.code === 'DEVICE_LIMIT_REACHED') {
// inviter l'utilisateur à libérer un appareil depuis son espace client
}Sans SDK : le protocole avec fetch
typescript
// keyline.ts — Node 20+, navigateurs, Electron (WebCrypto)
const API = 'https://keyline.example.com/api/v1';
const APPLICATION_ID = '<application_id>';
// Clés publiques de l'application (dashboard → Applications → Clés de signature), indexées par kid.
const PUBLIC_KEYS: Record<string, string> = {
'<kid>': `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`,
};
export class LicenseError extends Error {
constructor(readonly code: string, message: string) { super(message); }
}
const fromB64 = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const b64url = (s: string) => fromB64(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '='));
const pemToDer = (pem: string) => fromB64(pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, ''));
/** Vérifie Keyline-Signature : ECDSA P-256 / SHA-256 sur "<ts>.<corps>". */
async function verify(header: string | null, body: string): Promise<void> {
if (!header) throw new LicenseError('UNSIGNED', 'Réponse non signée');
const parts = Object.fromEntries(header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]));
const pem = PUBLIC_KEYS[parts.kid];
if (!pem) throw new LicenseError('UNKNOWN_KEY', 'Clé de signature inconnue');
const key = await crypto.subtle.importKey('spki', pemToDer(pem), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, b64url(parts.sig), new TextEncoder().encode(`${parts.ts}.${body}`));
if (!ok) throw new LicenseError('BAD_SIGNATURE', 'Signature invalide');
}
async function call<T>(endpoint: string, payload: Record<string, unknown>, attempt = 0): Promise<T> {
const nonce = crypto.randomUUID().replaceAll('-', '');
let res: Response;
try {
res = await fetch(`${API}/licenses/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ application_id: APPLICATION_ID, nonce, ...payload }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
// Erreur réseau : nouvelle tentative avec backoff (3 essais).
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
return call(endpoint, payload, attempt + 1);
}
throw err;
}
const body = await res.text();
if (res.status === 429 && attempt < 2) {
await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 1) * 1000));
return call(endpoint, payload, attempt + 1);
}
await verify(res.headers.get('Keyline-Signature'), body);
const data = JSON.parse(body);
if (data.nonce !== nonce) throw new LicenseError('REPLAY', 'Nonce inattendu');
if (!data.success) throw new LicenseError(data.error.code, data.error.message);
return data as T;
}
export const activate = (license_key: string, device_id: string, app_version?: string) =>
call<{ license: { expires_at: string | null; max_devices: number }; session: { token: string; expires_in: number } }>('activate', { license_key, device_id, app_version });
export const validate = (session_token: string) => call('validate', { session_token });
export const heartbeat = (session_token: string) => call<{ session: { expires_in: number } }>('heartbeat', { session_token });
export const deactivate = (session_token: string) => call('deactivate', { session_token });typescript
const { session } = await activate(userKey, await deviceId(), '1.4.2');
let token = session.token;
// Heartbeat avant l'expiration de la session (ici aux 2/3 de sa durée).
setInterval(async () => {
try {
await heartbeat(token);
} catch (err) {
if (err instanceof LicenseError && ['SESSION_EXPIRED', 'LICENSE_REVOKED', 'LICENSE_EXPIRED'].includes(err.code)) {
lockPremiumFeatures();
}
}
}, (session.expires_in * 1000 * 2) / 3);L’API publique autorise CORS (sans cookies) : ce client fonctionne depuis une application web ou Electron. Ne l’utilisez jamais avec une clé API.