diff --git a/.changeset/license-payload-type.md b/.changeset/license-payload-type.md new file mode 100644 index 0000000000..7fa08cc72e --- /dev/null +++ b/.changeset/license-payload-type.md @@ -0,0 +1,5 @@ +--- +"@milaboratories/pl-client": minor +--- + +Add `LicensePayload` type and `decodeLicenseToken` helper, describing the decoded body of a Platforma license token next to the Maintenance API `license()` call that returns it. The type includes the optional `le` claim (the license's own expiration, independent of the short-lived token TTL `e`). Includes a test that fetches the license from a live backend and asserts the required payload fields are always present. diff --git a/lib/node/pl-client/src/core/license.test.ts b/lib/node/pl-client/src/core/license.test.ts new file mode 100644 index 0000000000..5ce7cd135b --- /dev/null +++ b/lib/node/pl-client/src/core/license.test.ts @@ -0,0 +1,46 @@ +import { getTestClient } from "../test/test_config"; +import { decodeLicenseToken, type LicensePayload } from "./license"; +import { test, expect } from "vitest"; + +/** + * Fetches the license token from a live backend, decodes it, and checks that the + * required {@link LicensePayload} fields are always populated. + * + * This doubles as an investigation surface: point it at any backend via + * `PL_ADDRESS` / `PL_TEST_USER` / `PL_TEST_PASSWORD` and read the logged payload + * (notably `e`, the expiration timestamp) to inspect that backend's license. + */ +test("license payload carries all required fields", async () => { + const client = await getTestClient(); + + const resp = await client.license(); + expect(resp.isOk).toBe(true); + + // `responseBody` is the raw licensing-server body: a JSON-encoded token string. + const token = JSON.parse(Buffer.from(resp.responseBody).toString("utf8")) as string; + + // decodeLicenseToken() itself asserts required fields are present and well-typed; + // an incomplete license from the backend would throw here. + const payload: LicensePayload = decodeLicenseToken(token); + + // Explicit expectations mirror the required fields of LicensePayload, kept here + // so the contract is visible and easy to extend for future investigations. + expect(typeof payload.v).toBe("number"); + expect(typeof payload.e).toBe("number"); + expect(typeof payload.u).toBe("string"); + expect(payload.u.length).toBeGreaterThan(0); + expect(typeof payload.m).toBe("string"); + + // Token expiration must come after the token's valid-from moment. + expect(payload.e).toBeGreaterThan(payload.v); + + console.log("license payload:", JSON.stringify(payload, null, 2)); + console.log("token valid from:", new Date(payload.v * 1000).toISOString()); + console.log("token expires at:", new Date(payload.e * 1000).toISOString()); + if (payload.le !== undefined) { + console.log("license expires at:", new Date(payload.le * 1000).toISOString()); + } + if (payload.w !== undefined) { + console.log("warn after:", new Date(payload.w * 1000).toISOString()); + } +}); diff --git a/lib/node/pl-client/src/core/license.ts b/lib/node/pl-client/src/core/license.ts new file mode 100644 index 0000000000..c27e5fcdaf --- /dev/null +++ b/lib/node/pl-client/src/core/license.ts @@ -0,0 +1,136 @@ +/** + * Monitoring mode for a single telemetry channel, as encoded in the license. + * + * See https://github.com/milaboratory/text/blob/main/features/monitoring/platforma-monitoring-prd.md + */ +export type LicenseMonitoringMode = "with_id" | "no_id" | "none"; + +/** + * License payload — the decoded body of a Platforma license token. + * + * The token is issued by the licensing server (milm2) and returned to clients + * verbatim by the backend's Maintenance API — see {@link PlClient.license}. The + * token has the form `I...`; this type + * describes the JSON found in `` once base64-decoded (via + * {@link decodeLicenseToken}). + * + * Issuer reference: https://github.com/milaboratory/milm2/blob/master/src/types/index.ts + * Backend counterpart: `core/pl/cmd/platforma/license.go` (`License` struct). + */ +export interface LicensePayload { + /** Unix timestamp (seconds) the license itself is valid from (issuance), not a token time. */ + v: number; + /** + * Unix timestamp (seconds) this license token expires after — the token's + * TTL, not the license's own validity period. Tokens are short-lived and + * re-minted (e.g. a 24h TTL yields `e` = issued-at + 86400) even when the + * underlying license is valid far longer, so `e` is not the license + * expiration date. + */ + e: number; + /** + * [optional] Unix timestamp (seconds) the license itself expires — the + * underlying license/contract end date, independent of the short-lived + * token TTL `e`. Derived from the contract expiration before any token-TTL + * clamping, so this is the field to use when reasoning about whether the + * license (not the current token) has expired. Absent on tokens from older + * licensing servers that predate the `le` claim. + */ + le?: number; + /** UID of the customer. */ + u: string; + /** Metric marker — attached to usage statistics. */ + m: string; + /** [optional] Fallback license code, used once the license has expired. */ + l?: string; + /** [optional] Unix timestamp (seconds); warn the user about expiration after this moment. */ + w?: number; + /** [optional] Warning text; if `w` is set while `wt` is absent, a default warning should be shown. */ + wt?: string; + /** [optional] Expiration text — shown to the user once the license has expired. */ + et?: string; + c?: Record; + t?: Record; + hw?: string; + f?: Record; + /** + * Monitoring configuration. + * https://github.com/milaboratory/text/blob/main/features/monitoring/platforma-monitoring-prd.md + */ + s?: { + usage?: LicenseMonitoringMode; + performance?: LicenseMonitoringMode; + errors?: LicenseMonitoringMode; + safeErrors?: LicenseMonitoringMode; + errorTraces?: LicenseMonitoringMode; + }; +} + +/** Fixed first segment of a well-formed license token. */ +const LICENSE_TOKEN_PREFIX = "I"; +/** Fixed watermark segment of a well-formed license token. */ +const LICENSE_TOKEN_WATERMARK = "CPECUVF"; + +/** + * Runtime guard: asserts that a value parsed from a license token carries every + * required field with the expected type. Throws a descriptive error otherwise. + * + * Only the always-present fields (`v`, `e`, `u`, `m`) are validated — everything + * else in {@link LicensePayload} is optional and issuer-dependent. Internal: + * callers get validation for free via {@link decodeLicenseToken}. + */ +function assertLicensePayload(value: unknown): asserts value is LicensePayload { + if (typeof value !== "object" || value === null) { + throw new Error(`invalid license payload: expected an object, got ${typeof value}`); + } + const p = value as Record; + const requireType = (field: string, type: "number" | "string") => { + if (typeof p[field] !== type) { + throw new Error( + `invalid license payload: field "${field}" must be a ${type}, got ${typeof p[field]}`, + ); + } + }; + requireType("v", "number"); + requireType("e", "number"); + requireType("u", "string"); + requireType("m", "string"); +} + +/** + * Decode a raw license token into a validated {@link LicensePayload}. + * + * Mirrors the desktop app's token parser and the backend's `NewLicenseFromPayload`: + * it splits the `I...` envelope, checks the + * fixed prefix/watermark, base64-decodes the payload segment and validates that + * all required fields are present. This does NOT verify the cryptographic + * signature — as token is used by MiLM manager to access its API, this signature + * is for MiLM itself, who issued the token. + */ +export function decodeLicenseToken(token: string): LicensePayload { + const segments = token.split("."); + const [prefix, base64Payload, watermark, signature] = segments; + if ( + segments.length !== 4 || + prefix !== LICENSE_TOKEN_PREFIX || + watermark !== LICENSE_TOKEN_WATERMARK || + typeof base64Payload !== "string" || + base64Payload.length === 0 || + typeof signature !== "string" || + signature.length === 0 + ) { + throw new Error("invalid license token: unexpected envelope format"); + } + + const payloadStr = Buffer.from(base64Payload, "base64").toString("utf8"); + + let parsed: unknown; + try { + parsed = JSON.parse(payloadStr); + } catch (cause) { + throw new Error("invalid license token: payload is not valid JSON", { cause }); + } + + assertLicensePayload(parsed); + return parsed; +} diff --git a/lib/node/pl-client/src/index.ts b/lib/node/pl-client/src/index.ts index 619813ca31..9856875b8b 100644 --- a/lib/node/pl-client/src/index.ts +++ b/lib/node/pl-client/src/index.ts @@ -2,6 +2,7 @@ export * from "./core/types"; export * as Pl from "./helpers/pl"; export * from "./core/config"; export * from "./core/client"; +export * from "./core/license"; export * from "./core/driver"; export * from "./core/transaction"; export * from "./core/errors";