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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,43 @@ jobs:
});
NODE

minting-deploy:
name: Minting service deploy
needs: [library]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/setup-node@v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Deploy minting service to Vercel (production)
working-directory: apps/minting-service
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_MINTING_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-minting-service"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Verify minting service health
env:
MINTING_URL: https://minting.threadplane.ai
run: |
for i in 1 2 3 4 5; do
if curl -sf -o /dev/null "$MINTING_URL/api/health"; then
echo "Minting service is healthy."
exit 0
fi
echo "Waiting for minting service; attempt $i/5..."
sleep 5
done
echo "::error::Minting service did not respond at $MINTING_URL/api/health"
exit 1

production-smoke:
name: Production smoke
needs: [deploy, demo-deploy]
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ jobs:
run: npm install -g npm@latest

- name: Lint, test, build publishable projects
env:
CACHEPLANE_LICENSE_PUBLIC_KEY: ${{ secrets.CACHEPLANE_LICENSE_PUBLIC_KEY }}
run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache

- name: Patch install telemetry into publishable manifests
Expand Down
578 changes: 578 additions & 0 deletions docs/superpowers/plans/2026-05-21-licensing-verification-runtime.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions examples/chat/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessC
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideNgafTelemetry } from '@ngaf/telemetry/browser';
import { LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph';
import { provideChat } from '@ngaf/chat';
import { routes } from './app.routes';
import { environment } from '../environments/environment';

Expand All @@ -17,5 +18,12 @@ export const appConfig: ApplicationConfig = {
provide: LANGGRAPH_THREADS_CONFIG,
useValue: { apiUrl: environment.langGraphApiUrl },
},
// Optional license token, populated from environment.license. When
// unset (the default in main), @ngaf/chat runs in advisory mode and
// logs a console.warn once. A smoke-test session can drop a real
// token into environment.ts to exercise the verify path.
provideChat({
license: environment.license,
}),
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const environment = {
enabled: false,
sampleRate: 1,
},
license: undefined as string | undefined,
};
1 change: 1 addition & 0 deletions examples/chat/angular/src/environments/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export const environment = {
endpoint: '/api/ingest',
sampleRate: 1,
},
license: undefined as string | undefined,
};
34 changes: 34 additions & 0 deletions libs/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ Free under PolyForm Noncommercial:

See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial use, [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md) for the commercial license summary, and the [Threadplane pricing page](https://threadplane.ai/pricing) for plans.

## Using a commercial license

After purchase, Threadplane emails a signed license token to the address on your receipt. Paste it into your app's `provideChat()` configuration:

```typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideChat } from '@ngaf/chat';

export const appConfig: ApplicationConfig = {
providers: [
provideChat({
license: 'eyJ…', // The token from your purchase email.
}),
],
};
```

The library verifies the token's signature on boot. A missing, expired, or tampered token logs a `console.warn` advisory but does not block rendering — chat continues to work either way. Tokens are validated offline; no calls to Threadplane are made at runtime.

The license string is safe to commit to source control if your repository is private, or to read from a build-time env var for public repositories:

```typescript
declare const NGAF_LICENSE_TOKEN: string | undefined;

providers: [
provideChat({
license: typeof NGAF_LICENSE_TOKEN === 'string' ? NGAF_LICENSE_TOKEN : undefined,
}),
],
```

(See `examples/chat/angular/` in the framework repo for a working example.)

## Runtime adapters

Chat primitives consume a runtime-neutral `Agent` contract. Two adapters ship today:
Expand Down
24 changes: 24 additions & 0 deletions libs/licensing/src/lib/run-license-check.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,28 @@ describe('runLicenseCheck', () => {
expect(second).toBe('tampered');
expect(warn).toHaveBeenCalledOnce();
});

it('returns the cached actual status on repeat calls, not a constant', async () => {
// No-token first call: status should be 'missing' (production) or
// 'noncommercial' (dev). Force the production posture so we get 'missing'.
const result1 = await runLicenseCheck({
package: '@ngaf/chat',
token: undefined,
publicKey: kp.publicKey,
isNoncommercial: false,
warn,
});
expect(result1).toBe('missing');

// Second call with the same (package, token) tuple: must return the
// same status that was computed, not the literal 'licensed'.
const result2 = await runLicenseCheck({
package: '@ngaf/chat',
token: undefined,
publicKey: kp.publicKey,
isNoncommercial: false,
warn,
});
expect(result2).toBe('missing');
});
});
12 changes: 7 additions & 5 deletions libs/licensing/src/lib/run-license-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ export interface RunLicenseCheckOptions {
warn?: (message: string) => void;
}

const done = new Set<string>();
const done = new Map<string, LicenseStatus>();

export async function runLicenseCheck(
options: RunLicenseCheckOptions,
): Promise<LicenseStatus> {
const key = `${options.package}|${options.token ?? ''}`;
if (done.has(key)) {
// Idempotent: re-running with identical inputs is a no-op.
return 'licensed';
const cached = done.get(key);
if (cached !== undefined) {
// Idempotent: re-running with identical inputs returns the same status
// that was computed on the first call (not a hard-coded 'licensed').
return cached;
}
done.add(key);

const nowSec = options.nowSec ?? Math.floor(Date.now() / 1000);
const verify = options.token
Expand All @@ -41,6 +42,7 @@ export async function runLicenseCheck(

emitNag(evaluated, { package: options.package, warn: options.warn });

done.set(key, evaluated.status);
return evaluated.status;
}

Expand Down
Loading