Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Introduction

`@ngaf/licensing` is the shared license-check helper used by the framework packages. It verifies compact Ed25519-signed tokens offline, evaluates the result into a small status set, emits non-blocking warnings, and sends non-blocking license telemetry when asked.
`@ngaf/licensing` is the shared license-check helper used by the framework packages. It verifies compact Ed25519-signed tokens offline, evaluates the result into a small status set, and emits non-blocking warnings when appropriate.

The package itself is MIT licensed. `COMMERCIAL.md` states that the libraries in this repository are free to use, modify, and distribute in commercial and noncommercial projects. The proprietary part called out there is the internal minting service, not this package.

Expand All @@ -12,11 +12,10 @@ The main entry point exports:
|-----|---------|
| `verifyLicense()` | verifies token signature against a public key |
| `evaluateLicense()` | turns a verify result and current time into a status |
| `runLicenseCheck()` | verifies, evaluates, warns once, and sends non-blocking telemetry |
| `runLicenseCheck()` | verifies, evaluates, and warns once |
| `emitNag()` | emits the warning for non-licensed statuses |
| `signLicense()` | signs claims with an Ed25519 private key |
| `inferNoncommercial()` | returns a default noncommercial hint from `NODE_ENV` |
| `createTelemetryClient()` | sends a license telemetry POST |
| `LICENSE_PUBLIC_KEY` | bundled public key |

`@noble/ed25519` is the only peer dependency.
Expand Down Expand Up @@ -66,7 +65,6 @@ The higher-level check is designed not to block app startup:

- signature verification is local;
- warning output goes through `console.warn` unless a custom `warn` function is supplied;
- telemetry is fire-and-forget;
- telemetry send failures are swallowed by the telemetry client.
- no network request is made by the licensing check.

The code returns statuses instead of throwing for normal license states. Consumers can choose what to do with the status, but the framework packages use it as a warning and visibility mechanism, not as an app kill switch.
25 changes: 1 addition & 24 deletions apps/website/content/docs/licensing/guides/ci-and-offline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ If you call `runLicenseCheck()` directly, inject the current time only when you
```ts
await runLicenseCheck({
package: '@ngaf/example',
version: '1.0.0',
token,
publicKey,
telemetryEndpoint,
nowSec: 1_735_689_600,
});
```
Expand All @@ -47,28 +45,7 @@ const result = evaluateLicense(verified, {
});
```

This does not emit warnings and does not send telemetry.

## Telemetry in licensing checks

`runLicenseCheck()` creates a telemetry client and calls `send()` without awaiting it. The telemetry body contains:

- package name;
- package version;
- license id from `claims.sub`, when present;
- an anonymous instance id;
- epoch-second timestamp.

The licensing telemetry helper opts out when either of these is set:

```bash
CACHEPLANE_TELEMETRY=0
CACHEPLANE_TELEMETRY=false
```

It also checks `globalThis.CACHEPLANE_TELEMETRY` and treats `false`, `0`, or `"0"` as opt-out values.

If `fetch` is not available, the telemetry send is a no-op. If the request fails, the error is swallowed.
This does not emit warnings. The higher-level `runLicenseCheck()` helper can emit warnings, but it does not make network requests.

## Signing tokens

Expand Down
4 changes: 0 additions & 4 deletions apps/website/content/docs/licensing/guides/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ import {

const status = await runLicenseCheck({
package: '@ngaf/example',
version: '1.0.0',
token: process.env.NGAF_LICENSE,
publicKey: LICENSE_PUBLIC_KEY,
telemetryEndpoint: 'https://telemetry.example.com/v1/ping',
isNoncommercial: inferNoncommercial(),
});
```
Expand Down Expand Up @@ -57,10 +55,8 @@ You can inject a custom warning sink:
```ts
await runLicenseCheck({
package: '@ngaf/example',
version: '1.0.0',
token,
publicKey,
telemetryEndpoint,
warn: (message) => logger.warn(message),
});
```
Expand Down
38 changes: 1 addition & 37 deletions apps/website/content/docs/licensing/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,11 @@ Turns a verify result into a `LicenseStatus`.
```ts
interface RunLicenseCheckOptions {
package: string;
version: string;
token?: string;
publicKey: Uint8Array;
telemetryEndpoint: string;
nowSec?: number;
isNoncommercial?: boolean;
warn?: (message: string) => void;
fetch?: typeof fetch;
}

function runLicenseCheck(
Expand All @@ -99,8 +96,7 @@ Runs the full check:

1. verifies the token when present;
2. evaluates the license status;
3. emits a warning when appropriate;
4. sends non-blocking telemetry.
3. emits a warning when appropriate.

Repeated calls with the same package and token are treated as already handled and return `licensed`.

Expand Down Expand Up @@ -138,35 +134,3 @@ function signLicense(
```

Signs claims with an Ed25519 private key and returns the compact token consumed by `verifyLicense()`.

## createTelemetryClient()

```ts
interface TelemetryEvent {
package: string;
version: string;
licenseId?: string;
}

interface CreateTelemetryClientOptions {
endpoint: string;
fetch?: typeof fetch;
generateInstanceId?: () => string;
}
```

`createTelemetryClient(options).send(event)` POSTs JSON to the configured endpoint unless telemetry is opted out or `fetch` is unavailable.

The request body uses snake-case fields:

```json
{
"package": "@ngaf/example",
"version": "1.0.0",
"license_id": "cus_123",
"anon_instance_id": "generated-id",
"ts": 1735689600
}
```

Send failures are swallowed.
8 changes: 0 additions & 8 deletions libs/chat/src/lib/provide-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ import {
import type { AngularRegistry } from '@ngaf/render';

const PACKAGE_NAME = '@ngaf/chat';
declare const __CACHEPLANE_CHAT_VERSION__: string | undefined;
const PACKAGE_VERSION =
typeof __CACHEPLANE_CHAT_VERSION__ !== 'undefined'
? __CACHEPLANE_CHAT_VERSION__
: '0.0.0-dev';
const TELEMETRY_ENDPOINT = 'https://telemetry.cacheplane.dev/v1/ping';

export interface ChatConfig {
/** Default render registry for generative UI components. */
Expand Down Expand Up @@ -42,10 +36,8 @@ export const CHAT_CONFIG = new InjectionToken<ChatConfig>('CHAT_CONFIG');
export function provideChat(config: ChatConfig) {
void runLicenseCheck({
package: PACKAGE_NAME,
version: PACKAGE_VERSION,
token: config.license,
publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY,
telemetryEndpoint: TELEMETRY_ENDPOINT,
isNoncommercial:
config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(),
});
Expand Down
11 changes: 0 additions & 11 deletions libs/langgraph/src/lib/agent.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ import {
import { AgentTransport } from './agent.types';

const PACKAGE_NAME = '@ngaf/langgraph';
// Wired up by the release pipeline — imported lazily to avoid a hard build-time
// dependency on package.json.
declare const __CACHEPLANE_AGENT_VERSION__: string | undefined;
const PACKAGE_VERSION =
typeof __CACHEPLANE_AGENT_VERSION__ !== 'undefined'
? __CACHEPLANE_AGENT_VERSION__
: '0.0.0-dev';
const TELEMETRY_ENDPOINT =
'https://telemetry.cacheplane.dev/v1/ping';

/**
* Global configuration for agent instances.
Expand Down Expand Up @@ -52,10 +43,8 @@ export function provideAgent(config: AgentConfig): Provider {
// Fire-and-forget license check. Never blocks DI resolution.
void runLicenseCheck({
package: PACKAGE_NAME,
version: PACKAGE_VERSION,
token: config.license,
publicKey: config.__licensePublicKey ?? LICENSE_PUBLIC_KEY,
telemetryEndpoint: TELEMETRY_ENDPOINT,
isNoncommercial:
config.__licenseEnvHint?.isNoncommercial ?? inferNoncommercial(),
});
Expand Down
11 changes: 4 additions & 7 deletions libs/licensing/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @ngaf/licensing

Offline Ed25519 license verification + non-blocking telemetry for the Cacheplane
Angular framework libraries.
Offline Ed25519 license verification for the Cacheplane Angular framework
libraries.

## Status

Expand All @@ -13,10 +13,7 @@ Private, pre-1.0. Consumed by `@ngaf/langgraph`, `@ngaf/render`, and
- `verifyLicense(token, publicKey)` — pure Ed25519 verification, no I/O.
- `evaluateLicense(result, { nowSec })` — returns one of
`licensed | grace | expired | missing | tampered | noncommercial`.
- `runLicenseCheck(options)` — runs verification, emits a single
`console.warn` with the `[cacheplane]` prefix when unlicensed, and fires a
non-blocking telemetry POST.
- `runLicenseCheck(options)` — runs verification and emits a single
`console.warn` with the `[cacheplane]` prefix when unlicensed.
- **Never throws from init** — every failure mode is reported via warn, never
by throwing or blocking the host application's startup.
- **Opt out of telemetry** — set `CACHEPLANE_TELEMETRY=0` in the environment, or
`globalThis.CACHEPLANE_TELEMETRY = false`.
6 changes: 0 additions & 6 deletions libs/licensing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ export type { LicenseStatus, EvaluateResult, EvaluateOptions } from './lib/evalu
export { evaluateLicense } from './lib/evaluate-license.js';
export type { EmitNagOptions } from './lib/nag.js';
export { emitNag } from './lib/nag.js';
export type {
TelemetryEvent,
TelemetryClient,
CreateTelemetryClientOptions,
} from './lib/telemetry.js';
export { createTelemetryClient } from './lib/telemetry.js';
export type { RunLicenseCheckOptions } from './lib/run-license-check.js';
export { runLicenseCheck } from './lib/run-license-check.js';
export { signLicense } from './lib/sign-license.js';
Expand Down
45 changes: 13 additions & 32 deletions libs/licensing/src/lib/run-license-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,40 @@ describe('runLicenseCheck', () => {
let kp: DevKeyPair;
let validToken: string;
let warn: ReturnType<typeof vi.fn>;
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(async () => {
kp = await generateKeyPair();
validToken = await signLicense(BASE, kp.privateKey);
warn = vi.fn();
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
__resetNagStateForTests();
__resetRunLicenseCheckStateForTests();
});
afterEach(() => {
__resetNagStateForTests();
__resetRunLicenseCheckStateForTests();
vi.restoreAllMocks();
});

it('does not warn with a valid token and still fires telemetry', async () => {
it('does not warn with a valid token and does not perform network I/O', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const status = await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
token: validToken,
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
expect(status).toBe('licensed');
expect(warn).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body.license_id).toBe('cus_abc');
expect(fetchSpy).not.toHaveBeenCalled();
});

it('warns when token is missing', async () => {
const status = await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
expect(status).toBe('missing');
expect(warn).toHaveBeenCalledOnce();
Expand All @@ -68,51 +60,40 @@ describe('runLicenseCheck', () => {
it('is idempotent per (package, token) pair', async () => {
await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
token: validToken,
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
token: validToken,
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
// Second call is a no-op: no extra warn (already guarded by nag dedupe anyway),
// and crucially no second telemetry POST.
expect(fetchMock).toHaveBeenCalledOnce();
// Second call is a no-op: no extra warn, already guarded by nag dedupe.
expect(warn).not.toHaveBeenCalled();
});

it('re-runs when token changes (e.g., after key rotation in the host)', async () => {
const otherToken = await signLicense({ ...BASE, sub: 'cus_xyz' }, kp.privateKey);
await runLicenseCheck({
const tamperedToken = `${validToken.slice(0, -1)}x`;
const first = await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
token: validToken,
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
await runLicenseCheck({
const second = await runLicenseCheck({
package: '@ngaf/langgraph',
version: '1.0.0',
token: otherToken,
token: tamperedToken,
publicKey: kp.publicKey,
nowSec: 1_900_000_000,
telemetryEndpoint: 'https://t.example.com/v1',
warn,
fetch: fetchMock,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(first).toBe('licensed');
expect(second).toBe('tampered');
expect(warn).toHaveBeenCalledOnce();
});
});
Loading
Loading