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.