diff --git a/COMMERCIAL.md b/COMMERCIAL.md
index 963690f8..421a62ff 100644
--- a/COMMERCIAL.md
+++ b/COMMERCIAL.md
@@ -4,12 +4,10 @@ Most libraries in this repository — `@ngaf/render`, `@ngaf/agent`, `@ngaf/lang
## `@ngaf/chat`
-Starting with the next published version, `@ngaf/chat` is dual-licensed:
+`@ngaf/chat` is dual-licensed:
-- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30-day commercial evaluation).
-- **Threadplane commercial license** for commercial production use.
-
-Historical MIT releases of `@ngaf/chat` remain under their original terms.
+- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30 calendar days of commercial evaluation from first commercial use).
+- **ThreadPlane Commercial license** for commercial production use. Sold via [threadplane.ai/pricing](https://threadplane.ai/pricing); see [/docs/licensing](https://threadplane.ai/docs/licensing) for installation.
See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/LICENSE-COMMERCIAL.md`](./libs/chat/LICENSE-COMMERCIAL.md), and [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md) for the full terms.
diff --git a/README.md b/README.md
index d818dc90..0e4c078c 100644
--- a/README.md
+++ b/README.md
@@ -131,4 +131,4 @@ That's it. `chat.messages()` and `chat.status()` are Angular Signals. Bind them
Most libraries in this repository (`@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/licensing`, `@ngaf/telemetry`, `@ngaf/design-tokens`) are released under the **MIT License** — free for any use, including commercial, with attribution.
-**`@ngaf/chat`** is the exception. Future versions are licensed under **PolyForm Noncommercial 1.0.0 OR a Threadplane commercial license**. Historical npm releases remain MIT. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), and [`COMMERCIAL.md`](./COMMERCIAL.md) for details.
+**`@ngaf/chat`** is the exception. It is dual-licensed under **PolyForm Noncommercial 1.0.0** for free noncommercial use, or a **ThreadPlane Commercial license** for production use inside a for-profit context. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), [`COMMERCIAL.md`](./COMMERCIAL.md), and [threadplane.ai/docs/licensing](https://threadplane.ai/docs/licensing) for details.
diff --git a/apps/minting-service/handlers/stripe-webhook.ts b/apps/minting-service/handlers/stripe-webhook.ts
index 6b5b58b7..dd0985ad 100644
--- a/apps/minting-service/handlers/stripe-webhook.ts
+++ b/apps/minting-service/handlers/stripe-webhook.ts
@@ -7,6 +7,7 @@ import {
deleteProcessedEvent,
upsertLicense,
getLicense,
+ getLicensesByCustomerId,
revokeLicense,
} from '@ngaf/db';
import { loadEnv } from '../src/lib/env.js';
@@ -58,6 +59,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse):
deleteProcessedEvent,
upsertLicense,
getLicense,
+ getLicensesByCustomerId,
revokeLicense,
mintToken,
sendLicenseEmail,
diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts
index f98f6a4b..d580fba0 100644
--- a/apps/minting-service/scripts/remint.ts
+++ b/apps/minting-service/scripts/remint.ts
@@ -57,7 +57,7 @@ export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise {
expiresAt: new Date('2027-04-20T00:00:00Z'),
});
- expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----');
+ expect(out.text).toContain('-----BEGIN THREADPLANE LICENSE-----');
expect(out.text).toContain('PAYLOAD.SIG');
- expect(out.text).toContain('-----END CACHEPLANE LICENSE-----');
+ expect(out.text).toContain('-----END THREADPLANE LICENSE-----');
});
it('subject includes tier and seat count with plural s for seats > 1', () => {
@@ -27,12 +27,12 @@ describe('renderLicenseEmail', () => {
it('subject uses singular seat for seats === 1', () => {
const out = renderLicenseEmail({
- tier: 'app_deployment',
+ tier: 'team',
seats: 1,
token: 't.s',
expiresAt: new Date('2027-04-20T00:00:00Z'),
});
- expect(out.subject).toBe('Your ThreadPlane license — app_deployment (1 seat)');
+ expect(out.subject).toBe('Your ThreadPlane license — team (1 seat)');
});
it('includes ISO 8601 UTC expiry in text body', () => {
@@ -54,6 +54,6 @@ describe('renderLicenseEmail', () => {
});
expect(out.html).toContain('
+ // .env
+ THREADPLANE_LICENSE=
Docs: https://threadplane.ai/docs/licensing
Questions: reply to this email.
@@ -48,16 +51,21 @@ Questions: reply to this email.
-- The ThreadPlane team
`;
- const html = `
Thanks for subscribing to ThreadPlane.
-
Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:
-
-----BEGIN CACHEPLANE LICENSE-----
+ const html = `
Thanks for your ThreadPlane license purchase.
+
Your license is valid for 12 months from today. Paste the token below into your @ngaf/chat configuration:
diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts
index 357a7d0c..4b2161f5 100644
--- a/apps/minting-service/src/lib/handlers.spec.ts
+++ b/apps/minting-service/src/lib/handlers.spec.ts
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { describe, it, expect, vi } from 'vitest';
import type Stripe from 'stripe';
-import { handleEvent, handleCheckoutCompleted, handleChargeRefunded, type HandlerDeps } from './handlers.js';
+import {
+ handleEvent,
+ handleSubscriptionCreated,
+ handleSubscriptionUpdated,
+ handleInvoicePaid,
+ handleChargeRefunded,
+ type HandlerDeps,
+} from './handlers.js';
function makeDeps(overrides: Partial = {}): HandlerDeps {
return {
@@ -11,6 +18,7 @@ function makeDeps(overrides: Partial = {}): HandlerDeps {
deleteProcessedEvent: vi.fn().mockResolvedValue(undefined),
upsertLicense: vi.fn(),
getLicense: vi.fn(),
+ getLicensesByCustomerId: vi.fn().mockResolvedValue([]),
revokeLicense: vi.fn(),
mintToken: vi.fn().mockResolvedValue('mock.token'),
sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_mock' }),
@@ -27,92 +35,92 @@ function evt(type: string, obj: unknown = {}): Stripe.Event {
return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event;
}
-function paymentSession(overrides: Partial = {}): Stripe.Checkout.Session {
+// 2027-01-01T00:00:00Z = 1798761600 (unix seconds)
+const PERIOD_END_EPOCH = 1798761600;
+const PERIOD_END_DATE = new Date(PERIOD_END_EPOCH * 1000);
+
+function subscription(overrides: Partial = {}): Stripe.Subscription {
return {
- id: 'cs_test_123',
- mode: 'payment',
- payment_intent: 'pi_test_123',
+ id: 'sub_test_123',
customer: 'cus_test_123',
- customer_details: { email: 'buyer@example.com' } as Stripe.Checkout.Session.CustomerDetails,
- line_items: {
+ status: 'active',
+ current_period_end: PERIOD_END_EPOCH,
+ metadata: {},
+ items: {
data: [
{
quantity: 1,
price: {
- metadata: { ngaf_tier_slug: 'indie' },
+ metadata: { ngaf_tier_slug: 'developer_seat' },
} as Stripe.Price,
- } as Stripe.LineItem,
+ } as Stripe.SubscriptionItem,
],
- } as Stripe.ApiList,
+ } as Stripe.ApiList,
...overrides,
- } as Stripe.Checkout.Session;
+ } as unknown as Stripe.Subscription;
+}
+
+function stripeWithCustomer(email: string | null = 'buyer@example.com'): Stripe {
+ return {
+ customers: {
+ retrieve: vi.fn().mockResolvedValue({ id: 'cus_test_123', email, deleted: false } as Stripe.Customer),
+ },
+ subscriptions: {
+ retrieve: vi.fn(),
+ },
+ } as unknown as Stripe;
}
describe('handleEvent', () => {
it('returns early if markEventProcessed returns false (duplicate)', async () => {
const deps = makeDeps({
markEventProcessed: vi.fn().mockResolvedValue(false),
+ stripe: stripeWithCustomer(),
});
- await handleEvent(evt('checkout.session.completed', { id: 'cs_x' }), deps);
+ await handleEvent(evt('customer.subscription.created', subscription()), deps);
expect(deps.upsertLicense).not.toHaveBeenCalled();
expect(deps.sendLicenseEmail).not.toHaveBeenCalled();
});
- it('no-ops on unknown event types (including subscription events)', async () => {
+ it('no-ops on unknown event types (checkout, subscription.deleted, payment_succeeded)', async () => {
const deps = makeDeps();
- await handleEvent(evt('customer.subscription.updated'), deps);
+ await handleEvent(evt('checkout.session.completed'), deps);
await handleEvent(evt('customer.subscription.deleted'), deps);
await handleEvent(evt('invoice.payment_succeeded'), deps);
expect(deps.upsertLicense).not.toHaveBeenCalled();
expect(deps.sendLicenseEmail).not.toHaveBeenCalled();
});
- it('dispatches checkout.session.completed to handleCheckoutCompleted', async () => {
- const session = paymentSession();
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
- await handleEvent(evt('checkout.session.completed', session), deps);
- expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('cs_test_123', expect.any(Object));
+ it('dispatches customer.subscription.created to handleSubscriptionCreated', async () => {
+ const sub = subscription();
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await handleEvent(evt('customer.subscription.created', sub), deps);
expect(deps.upsertLicense).toHaveBeenCalledTimes(1);
expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1);
});
it('compensating-deletes the processed-event marker when handler throws', async () => {
- const session = paymentSession({ payment_intent: null });
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
+ const sub = subscription({ items: { data: [] } as unknown as Stripe.ApiList });
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
await expect(
- handleEvent(evt('checkout.session.completed', session), deps),
- ).rejects.toThrow(/no payment_intent/);
+ handleEvent(evt('customer.subscription.created', sub), deps),
+ ).rejects.toThrow(/no line items/);
expect(deps.deleteProcessedEvent).toHaveBeenCalledTimes(1);
});
});
-describe('handleCheckoutCompleted', () => {
- it('mints, upserts, and emails on a complete one-time payment session', async () => {
- const session = paymentSession();
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
- await handleCheckoutCompleted(session, deps);
+describe('handleSubscriptionCreated', () => {
+ it('mints, upserts, and emails on a new subscription', async () => {
+ const sub = subscription();
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await handleSubscriptionCreated(sub, deps);
expect(deps.mintToken).toHaveBeenCalledWith(
expect.objectContaining({
stripeCustomerId: 'cus_test_123',
- tier: 'indie',
+ tier: 'developer_seat',
seats: 1,
- expiresAt: expect.any(Date),
+ expiresAt: PERIOD_END_DATE,
}),
'a'.repeat(64),
);
@@ -120,125 +128,240 @@ describe('handleCheckoutCompleted', () => {
{} as never,
expect.objectContaining({
stripeCustomerId: 'cus_test_123',
- stripePaymentId: 'pi_test_123',
+ stripeSubscriptionId: 'sub_test_123',
customerEmail: 'buyer@example.com',
- tier: 'indie',
+ tier: 'developer_seat',
seats: 1,
+ expiresAt: PERIOD_END_DATE,
lastToken: 'mock.token',
}),
);
expect(deps.sendLicenseEmail).toHaveBeenCalledWith(
expect.objectContaining({
- from: 'noreply@example.com',
to: 'buyer@example.com',
- vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }),
+ vars: expect.objectContaining({ tier: 'developer_seat', seats: 1, token: 'mock.token' }),
}),
);
});
- it('skips subscription-mode sessions without minting', async () => {
- const session = paymentSession({ mode: 'subscription' });
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
- await handleCheckoutCompleted(session, deps);
- expect(deps.mintToken).not.toHaveBeenCalled();
- expect(deps.upsertLicense).not.toHaveBeenCalled();
- expect(deps.sendLicenseEmail).not.toHaveBeenCalled();
+ it('reads tier from subscription.metadata.ngaf_tier_slug, overriding price metadata', async () => {
+ const sub = subscription({
+ metadata: { ngaf_tier_slug: 'team' },
+ items: {
+ data: [
+ {
+ quantity: 5,
+ price: { metadata: { ngaf_tier_slug: 'developer_seat' } } as Stripe.Price,
+ } as Stripe.SubscriptionItem,
+ ],
+ } as Stripe.ApiList,
+ });
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await handleSubscriptionCreated(sub, deps);
+ expect(deps.upsertLicense).toHaveBeenCalledWith(
+ {} as never,
+ expect.objectContaining({ tier: 'team', seats: 5 }),
+ );
});
- it('throws when the session has no payment_intent', async () => {
- const session = paymentSession({ payment_intent: null });
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
- await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no payment_intent/);
+ it('throws if subscription has no current_period_end', async () => {
+ const sub = subscription({ current_period_end: undefined as unknown as number });
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await expect(handleSubscriptionCreated(sub, deps)).rejects.toThrow(/current_period_end/);
});
- it('throws when the session has no customer', async () => {
- const session = paymentSession({ customer: null });
- const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
- },
- } as unknown as Stripe;
- const deps = makeDeps({ stripe });
- await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/customer_creation/);
+ it('throws if the customer has no email', async () => {
+ const sub = subscription();
+ const deps = makeDeps({ stripe: stripeWithCustomer(null) });
+ await expect(handleSubscriptionCreated(sub, deps)).rejects.toThrow(/no email/);
});
+});
+
+describe('handleSubscriptionUpdated', () => {
+ function existing(overrides: Partial<{ seats: number; expiresAt: Date }> = {}) {
+ return {
+ id: 'lic_1',
+ stripeCustomerId: 'cus_test_123',
+ stripeSubscriptionId: 'sub_test_123',
+ customerEmail: 'existing@example.com',
+ tier: 'developer_seat',
+ seats: 1,
+ expiresAt: PERIOD_END_DATE,
+ lastToken: 'old.token',
+ revokedAt: null,
+ issuedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+ }
- it('dispatches charge.refunded to handleChargeRefunded', async () => {
- const charge = { id: 'ch_x', payment_intent: 'pi_test_123' } as Stripe.Charge;
+ it('re-mints when seats change', async () => {
+ const sub = subscription({
+ items: {
+ data: [
+ {
+ quantity: 3,
+ price: { metadata: { ngaf_tier_slug: 'developer_seat' } } as Stripe.Price,
+ } as Stripe.SubscriptionItem,
+ ],
+ } as Stripe.ApiList,
+ });
const deps = makeDeps({
- getLicense: vi.fn().mockResolvedValue({
- customerEmail: 'buyer@example.com',
- tier: 'indie',
- }),
- revokeLicense: vi.fn().mockResolvedValue({}),
+ stripe: stripeWithCustomer(),
+ getLicense: vi.fn().mockResolvedValue(existing({ seats: 1 })),
});
- await handleEvent(evt('charge.refunded', charge), deps);
- expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123');
- expect(deps.sendRevocationEmail).toHaveBeenCalledWith(
- expect.objectContaining({
- to: 'buyer@example.com',
- vars: { tier: 'indie' },
- }),
+ await handleSubscriptionUpdated(sub, deps);
+ expect(deps.upsertLicense).toHaveBeenCalledWith(
+ {} as never,
+ expect.objectContaining({ seats: 3 }),
);
+ expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1);
});
- it('throws when the session has no customer email', async () => {
- const session = paymentSession({
- customer_details: { email: null } as Stripe.Checkout.Session.CustomerDetails,
+ it('re-mints when period_end changes', async () => {
+ const sub = subscription({ current_period_end: PERIOD_END_EPOCH + 86400 });
+ const deps = makeDeps({
+ stripe: stripeWithCustomer(),
+ getLicense: vi.fn().mockResolvedValue(existing()),
});
+ await handleSubscriptionUpdated(sub, deps);
+ expect(deps.upsertLicense).toHaveBeenCalledTimes(1);
+ expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it('no-ops on status-only change with same seats/period (does not revoke immediately)', async () => {
+ const sub = subscription({ status: 'unpaid' });
+ const deps = makeDeps({
+ stripe: stripeWithCustomer(),
+ getLicense: vi.fn().mockResolvedValue(existing()),
+ });
+ await handleSubscriptionUpdated(sub, deps);
+ expect(deps.upsertLicense).not.toHaveBeenCalled();
+ expect(deps.sendLicenseEmail).not.toHaveBeenCalled();
+ expect(deps.revokeLicense).not.toHaveBeenCalled();
+ });
+
+ it('mints fresh if no existing license is found', async () => {
+ const sub = subscription();
+ const deps = makeDeps({
+ stripe: stripeWithCustomer(),
+ getLicense: vi.fn().mockResolvedValue(null),
+ });
+ await handleSubscriptionUpdated(sub, deps);
+ expect(deps.upsertLicense).toHaveBeenCalledTimes(1);
+ expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('handleInvoicePaid', () => {
+ function invoice(overrides: Partial = {}): Stripe.Invoice {
+ return {
+ id: 'in_test',
+ billing_reason: 'subscription_cycle',
+ subscription: 'sub_test_123',
+ ...overrides,
+ } as unknown as Stripe.Invoice;
+ }
+
+ it('re-mints on a renewal invoice (subscription_cycle)', async () => {
+ const sub = subscription({ current_period_end: PERIOD_END_EPOCH + 30 * 86400 });
const stripe = {
- checkout: {
- sessions: { retrieve: vi.fn().mockResolvedValue(session) },
+ customers: {
+ retrieve: vi.fn().mockResolvedValue({ id: 'cus_test_123', email: 'buyer@example.com' } as Stripe.Customer),
+ },
+ subscriptions: {
+ retrieve: vi.fn().mockResolvedValue(sub),
},
} as unknown as Stripe;
const deps = makeDeps({ stripe });
- await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/);
+ await handleInvoicePaid(invoice(), deps);
+ expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith('sub_test_123');
+ expect(deps.upsertLicense).toHaveBeenCalledWith(
+ {} as never,
+ expect.objectContaining({
+ stripeSubscriptionId: 'sub_test_123',
+ expiresAt: new Date((PERIOD_END_EPOCH + 30 * 86400) * 1000),
+ }),
+ );
+ expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips the first-time subscription_create invoice', async () => {
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await handleInvoicePaid(invoice({ billing_reason: 'subscription_create' }), deps);
+ expect(deps.upsertLicense).not.toHaveBeenCalled();
+ });
+
+ it('skips invoices with no subscription', async () => {
+ const deps = makeDeps({ stripe: stripeWithCustomer() });
+ await handleInvoicePaid(
+ invoice({ subscription: null } as unknown as Partial),
+ deps,
+ );
+ expect(deps.upsertLicense).not.toHaveBeenCalled();
});
});
describe('handleChargeRefunded', () => {
const charge = (overrides: Partial = {}): Stripe.Charge =>
- ({ id: 'ch_test', payment_intent: 'pi_test_123', ...overrides }) as Stripe.Charge;
+ ({ id: 'ch_test', customer: 'cus_test_123', ...overrides }) as Stripe.Charge;
- it('revokes and emails when a license exists for the charge', async () => {
- const deps = makeDeps({
- getLicense: vi.fn().mockResolvedValue({
+ it('revokes all active licenses for the customer and emails each', async () => {
+ const licenses = [
+ {
+ stripeSubscriptionId: 'sub_a',
customerEmail: 'buyer@example.com',
tier: 'developer_seat',
- }),
+ revokedAt: null,
+ },
+ {
+ stripeSubscriptionId: 'sub_b',
+ customerEmail: 'buyer@example.com',
+ tier: 'team',
+ revokedAt: null,
+ },
+ ];
+ const deps = makeDeps({
+ getLicensesByCustomerId: vi.fn().mockResolvedValue(licenses),
revokeLicense: vi.fn().mockResolvedValue({}),
});
await handleChargeRefunded(charge(), deps);
- expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123');
- expect(deps.sendRevocationEmail).toHaveBeenCalledWith(
- expect.objectContaining({
- to: 'buyer@example.com',
- vars: { tier: 'developer_seat' },
- }),
- );
+ expect(deps.revokeLicense).toHaveBeenCalledTimes(2);
+ expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'sub_a');
+ expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'sub_b');
+ expect(deps.sendRevocationEmail).toHaveBeenCalledTimes(2);
});
- it('no-ops when no license exists for the payment_intent', async () => {
+ it('skips already-revoked licenses', async () => {
const deps = makeDeps({
- getLicense: vi.fn().mockResolvedValue(null),
+ getLicensesByCustomerId: vi.fn().mockResolvedValue([
+ {
+ stripeSubscriptionId: 'sub_a',
+ customerEmail: 'buyer@example.com',
+ tier: 'developer_seat',
+ revokedAt: new Date(),
+ },
+ ]),
+ revokeLicense: vi.fn().mockResolvedValue({}),
+ });
+ await handleChargeRefunded(charge(), deps);
+ expect(deps.revokeLicense).not.toHaveBeenCalled();
+ expect(deps.sendRevocationEmail).not.toHaveBeenCalled();
+ });
+
+ it('no-ops when the customer has no licenses', async () => {
+ const deps = makeDeps({
+ getLicensesByCustomerId: vi.fn().mockResolvedValue([]),
});
await handleChargeRefunded(charge(), deps);
expect(deps.revokeLicense).not.toHaveBeenCalled();
expect(deps.sendRevocationEmail).not.toHaveBeenCalled();
});
- it('no-ops when charge has no payment_intent', async () => {
+ it('no-ops when charge has no customer', async () => {
const deps = makeDeps();
- await handleChargeRefunded(charge({ payment_intent: null }), deps);
- expect(deps.getLicense).not.toHaveBeenCalled();
+ await handleChargeRefunded(charge({ customer: null }), deps);
+ expect(deps.getLicensesByCustomerId).not.toHaveBeenCalled();
});
});
diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts
index a85109ea..574024b6 100644
--- a/apps/minting-service/src/lib/handlers.ts
+++ b/apps/minting-service/src/lib/handlers.ts
@@ -7,7 +7,7 @@ import type {
} from '@ngaf/db';
import type { MintInput } from './sign.js';
import type { LicenseEmailVars, RevocationEmailVars } from './email.js';
-import { extractTier, computeSeats } from './tier.js';
+import { extractTier, computeSeats, type MintableTier } from './tier.js';
/**
* All external collaborators are injected so handlers are unit-testable.
@@ -18,8 +18,9 @@ export interface HandlerDeps {
markEventProcessed: (db: Db, id: string, type: string) => Promise;
deleteProcessedEvent: (db: Db, id: string) => Promise;
upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise;
- getLicense: (db: Db, stripePaymentId: string) => Promise;
- revokeLicense: (db: Db, stripePaymentId: string) => Promise;
+ getLicense: (db: Db, stripeSubscriptionId: string) => Promise;
+ getLicensesByCustomerId: (db: Db, customerId: string) => Promise;
+ revokeLicense: (db: Db, stripeSubscriptionId: string) => Promise;
mintToken: (input: MintInput, privateKeyHex: string) => Promise;
sendLicenseEmail: (args: {
resendApiKey: string;
@@ -45,8 +46,14 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi
try {
switch (event.type) {
- case 'checkout.session.completed':
- await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps);
+ case 'customer.subscription.created':
+ await handleSubscriptionCreated(event.data.object as Stripe.Subscription, deps);
+ break;
+ case 'customer.subscription.updated':
+ await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, deps);
+ break;
+ case 'invoice.paid':
+ await handleInvoicePaid(event.data.object as Stripe.Invoice, deps);
break;
case 'charge.refunded':
await handleChargeRefunded(event.data.object as Stripe.Charge, deps);
@@ -60,53 +67,78 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi
}
}
-/**
- * Handles a completed Stripe Checkout session in `mode: 'payment'`
- * (one-time payment). Subscription mode is not handled — the only paid
- * tiers ship as one-time 12-month payments. A subscription-mode session
- * is logged and dropped.
- */
-export async function handleCheckoutCompleted(
- session: Stripe.Checkout.Session,
- deps: HandlerDeps,
-): Promise {
- const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, {
- expand: ['line_items.data.price'],
- });
+interface SubscriptionLineFacts {
+ tier: MintableTier;
+ seats: number;
+ quantity: number;
+}
- if (expanded.mode !== 'payment') {
- console.log(`handleCheckoutCompleted: skipping non-payment session ${session.id} (mode=${expanded.mode})`);
- return;
+function readSubscriptionFacts(subscription: Stripe.Subscription): SubscriptionLineFacts {
+ const item = subscription.items?.data?.[0];
+ if (!item) {
+ throw new Error(`subscription ${subscription.id} has no line items`);
}
+ const subMetadata = (subscription.metadata ?? {}) as Record;
+ const priceMetadata = (item.price?.metadata ?? {}) as Record;
+ const merged: Record = {
+ ...priceMetadata,
+ ...(subMetadata['ngaf_tier_slug'] ? { ngaf_tier_slug: subMetadata['ngaf_tier_slug'] } : {}),
+ };
+ const tier = extractTier(merged);
+ const quantity = item.quantity ?? 1;
+ const seats = computeSeats(tier, quantity);
+ return { tier, seats, quantity };
+}
- const lineItem = expanded.line_items?.data?.[0];
- if (!lineItem) {
- throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`);
- }
- const priceMetadata = (lineItem.price?.metadata ?? {}) as Record;
- const tier = extractTier(priceMetadata);
- const seats = computeSeats(tier, lineItem.quantity);
-
- const paymentId = typeof expanded.payment_intent === 'string'
- ? expanded.payment_intent
- : expanded.payment_intent?.id;
- if (!paymentId) {
- throw new Error(`handleCheckoutCompleted: session ${session.id} has no payment_intent`);
+function readCustomerId(subscription: Stripe.Subscription): string {
+ const customerId = typeof subscription.customer === 'string'
+ ? subscription.customer
+ : subscription.customer?.id;
+ if (!customerId) {
+ throw new Error(`subscription ${subscription.id} has no customer`);
}
+ return customerId;
+}
- const customerId = typeof expanded.customer === 'string'
- ? expanded.customer
- : expanded.customer?.id;
- if (!customerId) {
- throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer (customer_creation: 'always' must be set on the Checkout session)`);
+function periodEnd(subscription: Stripe.Subscription): Date {
+ // current_period_end is unix seconds; convert to Date.
+ const epoch = (subscription as unknown as { current_period_end?: number }).current_period_end;
+ if (!epoch) {
+ throw new Error(`subscription ${subscription.id} has no current_period_end`);
}
+ return new Date(epoch * 1000);
+}
- const email = expanded.customer_details?.email;
+async function resolveCustomerEmail(
+ subscription: Stripe.Subscription,
+ deps: HandlerDeps,
+): Promise {
+ const customer = subscription.customer;
+ if (customer && typeof customer !== 'string') {
+ const email = (customer as Stripe.Customer).email;
+ if (email) return email;
+ }
+ const customerId = readCustomerId(subscription);
+ const fetched = await deps.stripe.customers.retrieve(customerId);
+ if ('deleted' in fetched && fetched.deleted) {
+ throw new Error(`subscription ${subscription.id}: customer ${customerId} is deleted`);
+ }
+ const email = (fetched as Stripe.Customer).email;
if (!email) {
- throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`);
+ throw new Error(`subscription ${subscription.id}: customer ${customerId} has no email`);
}
+ return email;
+}
- const expiresAt = new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000);
+async function mintAndEmail(
+ subscription: Stripe.Subscription,
+ deps: HandlerDeps,
+ opts: { email?: string } = {},
+): Promise {
+ const { tier, seats } = readSubscriptionFacts(subscription);
+ const customerId = readCustomerId(subscription);
+ const expiresAt = periodEnd(subscription);
+ const email = opts.email ?? (await resolveCustomerEmail(subscription, deps));
const token = await deps.mintToken(
{ stripeCustomerId: customerId, tier, seats, expiresAt },
@@ -115,7 +147,7 @@ export async function handleCheckoutCompleted(
await deps.upsertLicense(deps.db, {
stripeCustomerId: customerId,
- stripePaymentId: paymentId,
+ stripeSubscriptionId: subscription.id,
customerEmail: email,
tier,
seats,
@@ -132,42 +164,107 @@ export async function handleCheckoutCompleted(
}
/**
- * Handles a Stripe charge.refunded event by revoking the matching license
- * and notifying the customer. Both partial and full refunds revoke; the
- * heuristic is that any refund signals the customer wants out, and they
- * can re-purchase if needed.
+ * Mint a license on a brand-new subscription.
+ */
+export async function handleSubscriptionCreated(
+ subscription: Stripe.Subscription,
+ deps: HandlerDeps,
+): Promise {
+ await mintAndEmail(subscription, deps);
+}
+
+/**
+ * Re-mint when seats or status changes. Status transitions to canceled/unpaid
+ * do NOT immediately revoke — the license expires naturally at
+ * current_period_end.
+ */
+export async function handleSubscriptionUpdated(
+ subscription: Stripe.Subscription,
+ deps: HandlerDeps,
+): Promise {
+ const existing = await deps.getLicense(deps.db, subscription.id);
+ if (!existing) {
+ // We never saw a `customer.subscription.created` for this one; treat as
+ // a fresh mint so we don't drop the customer on the floor.
+ await mintAndEmail(subscription, deps);
+ return;
+ }
+
+ const facts = readSubscriptionFacts(subscription);
+ const seatsChanged = facts.seats !== existing.seats;
+ const expiresAtNew = periodEnd(subscription);
+ const periodChanged = expiresAtNew.getTime() !== existing.expiresAt.getTime();
+
+ if (seatsChanged || periodChanged) {
+ await mintAndEmail(subscription, deps, { email: existing.customerEmail });
+ }
+ // Other status transitions (active <-> past_due, canceled-at-period-end)
+ // are no-ops: the existing license stays valid through its expires_at.
+}
+
+/**
+ * On a renewal invoice (`billing_reason: subscription_cycle`), re-mint with
+ * the new period_end and email the new token. Skip non-subscription invoices
+ * and the first-time `subscription_create` invoice (handled by
+ * customer.subscription.created).
+ */
+export async function handleInvoicePaid(
+ invoice: Stripe.Invoice,
+ deps: HandlerDeps,
+): Promise {
+ if (invoice.billing_reason !== 'subscription_cycle') {
+ return;
+ }
+ const subscriptionRef = (invoice as unknown as {
+ subscription?: string | Stripe.Subscription | null;
+ }).subscription;
+ const subscriptionId = typeof subscriptionRef === 'string'
+ ? subscriptionRef
+ : subscriptionRef?.id;
+ if (!subscriptionId) {
+ console.log(`handleInvoicePaid: invoice ${invoice.id} has no subscription`);
+ return;
+ }
+
+ const subscription = await deps.stripe.subscriptions.retrieve(subscriptionId);
+ await mintAndEmail(subscription, deps);
+}
+
+/**
+ * Revoke any active license owned by the customer behind this charge.
*
- * Idempotent: re-runs on a refunded charge whose license is already
- * revoked simply re-send the email; the DB row stays revoked.
+ * One-time payments are gone, so we can't key the license directly off
+ * the charge's payment_intent. Instead we look up the customer and revoke
+ * every non-revoked license they own. The customer can re-subscribe to
+ * re-issue.
*/
export async function handleChargeRefunded(
charge: Stripe.Charge,
deps: HandlerDeps,
): Promise {
- const paymentId = typeof charge.payment_intent === 'string'
- ? charge.payment_intent
- : charge.payment_intent?.id;
- if (!paymentId) {
- console.log(`handleChargeRefunded: charge ${charge.id} has no payment_intent`);
+ const customerId = typeof charge.customer === 'string'
+ ? charge.customer
+ : charge.customer?.id;
+ if (!customerId) {
+ console.log(`handleChargeRefunded: charge ${charge.id} has no customer`);
return;
}
- const existing = await deps.getLicense(deps.db, paymentId);
- if (!existing) {
- console.log(`handleChargeRefunded: no license for payment_intent ${paymentId}`);
+ const licenses = await deps.getLicensesByCustomerId(deps.db, customerId);
+ const active = licenses.filter((l) => !l.revokedAt);
+ if (active.length === 0) {
+ console.log(`handleChargeRefunded: no active licenses for customer ${customerId}`);
return;
}
- const revoked = await deps.revokeLicense(deps.db, paymentId);
- if (!revoked) {
- console.log(`handleChargeRefunded: revokeLicense returned null for ${paymentId}`);
- return;
+ for (const license of active) {
+ const revoked = await deps.revokeLicense(deps.db, license.stripeSubscriptionId);
+ if (!revoked) continue;
+ await deps.sendRevocationEmail({
+ resendApiKey: deps.resendApiKey,
+ from: deps.emailFrom,
+ to: license.customerEmail,
+ vars: { tier: license.tier as 'developer_seat' | 'team' },
+ });
}
-
- await deps.sendRevocationEmail({
- resendApiKey: deps.resendApiKey,
- from: deps.emailFrom,
- to: existing.customerEmail,
- vars: { tier: existing.tier as 'indie' | 'developer_seat' | 'app_deployment' },
- });
}
diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts
index 433f4d86..21085f56 100644
--- a/apps/minting-service/src/lib/sign.spec.ts
+++ b/apps/minting-service/src/lib/sign.spec.ts
@@ -42,7 +42,7 @@ describe('mintToken', () => {
mintToken(
{
stripeCustomerId: 'cus_x',
- tier: 'app_deployment',
+ tier: 'team',
seats: 1,
expiresAt: new Date('2027-01-01T00:00:00Z'),
},
diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts
index 274ecbf6..b0de2a2a 100644
--- a/apps/minting-service/src/lib/tier.spec.ts
+++ b/apps/minting-service/src/lib/tier.spec.ts
@@ -2,16 +2,12 @@
import { extractTier, computeSeats } from './tier.js';
describe('extractTier', () => {
- it('returns indie from price metadata', () => {
- expect(extractTier({ ngaf_tier_slug: 'indie' })).toBe('indie');
- });
-
it('returns developer_seat from price metadata', () => {
expect(extractTier({ ngaf_tier_slug: 'developer_seat' })).toBe('developer_seat');
});
- it('returns app_deployment from price metadata', () => {
- expect(extractTier({ ngaf_tier_slug: 'app_deployment' })).toBe('app_deployment');
+ it('returns team from price metadata', () => {
+ expect(extractTier({ ngaf_tier_slug: 'team' })).toBe('team');
});
it('throws when ngaf_tier_slug is missing', () => {
@@ -32,12 +28,9 @@ describe('computeSeats', () => {
expect(computeSeats('developer_seat', 5)).toBe(5);
});
- it('returns 1 for app_deployment regardless of quantity', () => {
- expect(computeSeats('app_deployment', 10)).toBe(1);
- });
-
- it('returns 1 for indie regardless of quantity', () => {
- expect(computeSeats('indie', 10)).toBe(1);
+ it('returns 5 for team regardless of quantity', () => {
+ expect(computeSeats('team', 1)).toBe(5);
+ expect(computeSeats('team', 10)).toBe(5);
});
it('defaults developer_seat to 1 when quantity is null', () => {
diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts
index ae593248..c99a3e0c 100644
--- a/apps/minting-service/src/lib/tier.ts
+++ b/apps/minting-service/src/lib/tier.ts
@@ -1,11 +1,13 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import type { LicenseTier } from '@ngaf/licensing';
-export type MintableTier = Extract;
+export type MintableTier = Extract;
-const VALID_TIERS: readonly MintableTier[] = ['indie', 'developer_seat', 'app_deployment'] as const;
+const VALID_TIERS: readonly MintableTier[] = ['developer_seat', 'team'] as const;
const METADATA_KEY = 'ngaf_tier_slug';
+const TEAM_SEAT_COUNT = 5;
+
/**
* Extract the tier slug from a Stripe price metadata bag.
* Throws if the field is missing or holds an unknown value.
@@ -27,12 +29,14 @@ export function extractTier(metadata: Record | null | undefined)
/**
* Compute the `seats` claim from the Stripe line-item quantity.
* - developer_seat: tracks Stripe quantity (minimum 1).
- * - indie: always 1.
- * - app_deployment: always 1.
+ * - team: always 5 (the bundle size baked into the SKU).
*/
export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number {
if (tier === 'developer_seat') {
return quantity && quantity > 0 ? quantity : 1;
}
+ if (tier === 'team') {
+ return TEAM_SEAT_COUNT;
+ }
return 1;
}
diff --git a/apps/website/content/docs/licensing/api/api-docs.json b/apps/website/content/docs/licensing/api/api-docs.json
index 9f450918..5d8e42be 100644
--- a/apps/website/content/docs/licensing/api/api-docs.json
+++ b/apps/website/content/docs/licensing/api/api-docs.json
@@ -158,7 +158,7 @@
"name": "LicenseTier",
"kind": "type",
"description": "The tier a license grants.",
- "signature": "\"indie\" | \"developer_seat\" | \"app_deployment\" | \"enterprise\"",
+ "signature": "\"developer_seat\" | \"team\" | \"enterprise\"",
"examples": []
},
{
diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts
index 58d32928..8639a5c0 100644
--- a/apps/website/e2e/website.spec.ts
+++ b/apps/website/e2e/website.spec.ts
@@ -23,7 +23,9 @@ test('landing page renders feature blocks (Stream/Render/Ship)', async ({ page }
test('pricing page shows plan cards', async ({ page }) => {
await page.goto('/pricing');
- await expect(page.getByText('Open Source').first()).toBeVisible();
+ await expect(page.getByText('Community').first()).toBeVisible();
+ await expect(page.getByText('Developer Seat').first()).toBeVisible();
+ await expect(page.getByText('Team').first()).toBeVisible();
await expect(page.getByText('Enterprise').first()).toBeVisible();
});
@@ -37,7 +39,7 @@ test('pricing page lead form validates required fields', async ({ page }) => {
await page.goto('/pricing');
const leadForm = page.locator('#lead-form form');
await expect(leadForm).toBeVisible();
- await leadForm.getByRole('button', { name: 'Get in touch' }).click();
+ await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click();
await expect(leadForm).toBeVisible();
expect(checkoutCalled).toBe(false);
expect(await leadForm.evaluate((form) => (form as HTMLFormElement).checkValidity())).toBe(false);
@@ -85,11 +87,12 @@ test('pricing lead form posts to /api/leads and renders success state', async ({
});
await page.goto('/pricing#lead-form');
- await page.getByLabel('Name').fill('Jane Smith');
- await page.getByLabel('Work email').fill('jane@acme.com');
- await page.getByLabel('Company').fill('Acme');
- await page.getByLabel('Tell us about your use case').fill('Volume seats and security review.');
- await page.getByRole('button', { name: 'Get in touch' }).click();
+ const leadForm = page.locator('#lead-form form');
+ await leadForm.getByLabel('Name').fill('Jane Smith');
+ await leadForm.getByLabel('Work email').fill('jane@acme.com');
+ await leadForm.getByLabel('Company').fill('Acme');
+ await leadForm.getByLabel('Tell us about your use case').fill('Volume seats and security review.');
+ await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click();
await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible();
expect(leadPayload).toMatchObject({
@@ -236,7 +239,7 @@ test('docs pages render canonical and social metadata', async ({ page }) => {
});
test('marketing pages render canonical and page-specific social URLs', async ({ page }) => {
- for (const route of ['/', '/angular', '/render', '/chat', '/pricing', '/contact', '/pilot-to-prod', '/solutions']) {
+ for (const route of ['/', '/langgraph', '/ag-ui', '/render', '/chat', '/pricing', '/contact', '/pilot-to-prod', '/solutions']) {
await page.goto(route);
const expectedUrl = route === '/' ? 'https://threadplane.ai' : `https://threadplane.ai${route}`;
@@ -273,7 +276,7 @@ test('representative docs pages do not create page-level horizontal overflow', a
test('marketing pages link to downloadable whitepaper PDFs', async ({ page }) => {
const expectedDownloads: Record = {
'/': '/whitepaper.pdf',
- '/angular': '/whitepapers/angular.pdf',
+ '/langgraph': '/whitepapers/angular.pdf',
'/render': '/whitepapers/render.pdf',
'/chat': '/whitepapers/chat.pdf',
};
diff --git a/apps/website/src/app/ag-ui/page.tsx b/apps/website/src/app/ag-ui/page.tsx
new file mode 100644
index 00000000..3631e449
--- /dev/null
+++ b/apps/website/src/app/ag-ui/page.tsx
@@ -0,0 +1,166 @@
+import Link from 'next/link';
+import { tokens } from '@ngaf/design-tokens';
+import { Container } from '../../components/ui/Container';
+import { Section } from '../../components/ui/Section';
+import { Eyebrow } from '../../components/ui/Eyebrow';
+import { Button } from '../../components/ui/Button';
+import { Pill } from '../../components/ui/Pill';
+import { BrowserFrame } from '../../components/ui/BrowserFrame';
+import { FeatureBlock } from '../../components/landing/FeatureBlock';
+import { FinalCTA } from '../../components/landing/FinalCTA';
+import { BackendsGrid } from '../../components/landing/ag-ui/BackendsGrid';
+import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata';
+
+export const metadata = createPageMetadata({
+ title: '@ngaf/ag-ui — Agent UI for Angular',
+ description: SHORT_POSITIONING_DESCRIPTION,
+ pathname: '/ag-ui',
+ type: 'website',
+});
+
+export default async function AgUiPage() {
+ return (
+ <>
+ {/* Hero */}
+
+
+
+ @ngaf/ag-ui
+
+ One adapter. Eight backends.
+
+
+ Build an Angular agent UI on any AG-UI-compatible runtime — LangGraph, CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands, CopilotKit. Same primitives, same chat surface, same testing story.
+
+
+
+
+
+
+ MIT
+ Angular 20+
+ AG-UI protocol
+
+
+ Already on LangGraph?{' '}
+
+ See @ngaf/langgraph
+ {' '}
+ for native streaming, checkpoints, and the typed LangGraph SDK path.
+
+ How the ThreadPlane licensing model works, who needs a paid license, and how to install your license token.
+
+
+
+
+
+
+
+
+
The model
+
+ Agent UI for Angular is a suite of libraries. Most are{' '}
+ MIT-licensed and free for any use,
+ commercial or not. Only @ngaf/chat is
+ dual-licensed.
+
+
+ @ngaf/chat is source-available under{' '}
+ PolyForm Noncommercial 1.0.0 for free
+ noncommercial use, or a ThreadPlane Commercial license{' '}
+ for production use inside a for-profit context. The same source ships under both — you don't get a
+ different build.
+
+
+
Do you need a paid license?
+
+ You need a ThreadPlane Commercial license if you use @ngaf/chat{' '}
+ in any of:
+
+
+
A commercial product or SaaS
+
An internal business tool inside a for-profit company
+
An agency deliverable or paid client project
+
Any application operated by or for a for-profit entity
+
+
You do not need a paid license for:
+
+
Personal, hobby, student, academic, or nonprofit projects
+
Public demos and tutorials
+
Open-source applications released under an OSI-approved license
+
Commercial evaluation, up to 30 calendar days from your first commercial use
+
+
+
+
+
+
+
+
+
Install your license
+
+ After purchase, ThreadPlane emails a signed license token to the address on your receipt. Paste it
+ into your app's provideChat(){' '}
+ configuration:
+
+ The library verifies the token's Ed25519 signature on boot. The check is{' '}
+ advisory-only: a missing, expired, or
+ tampered token logs a console.warn but
+ never blocks rendering. Verification is fully offline; no calls leave your app at runtime.
+
+
+ The token is safe to commit to a private repository, or to read from a build-time environment variable
+ for public repos. Public-repo demos are exempt from the commercial-use definition, but if your
+ public repo backs a commercial product, the deployed bundle does need a license.
+
+
+
+
+
+
+
+
+
Tier scoping
+
+ Pick the tier that matches how you'll deploy. All paid tiers grant the same{' '}
+ ThreadPlane Commercial license; the difference is the scope of use and the number of seats.
+
+ Paid tiers are recurring subscriptions. Annual saves ~15% vs monthly. Cancel anytime — the license
+ stays valid through the end of the current paid period.
+
+
+
+
+
+
+
+
+
Evaluation
+
+ You may use @ngaf/chat commercially
+ for 30 calendar days from your first
+ commercial use as a good-faith evaluation. There is no telemetry, no registration, no email check —
+ we trust you to count the days. After 30 days you must either purchase a license or stop the
+ commercial use.
+
+
+
Refunds
+
+ If you refund a license through Stripe, the token is revoked automatically and we email a confirmation.
+ The verification check warns on boot. There's no clawback of the source code you already have —
+ everything is source-available under PolyForm Noncommercial by default.
+
+
+
Questions
+
+ Volume pricing, multi-app licensing, audit clauses, custom terms — any of those, reach out and we'll
+ work it out.
+
+
+
+
+
+ Pricing FAQ →
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/website/src/app/angular/page.tsx b/apps/website/src/app/langgraph/page.tsx
similarity index 96%
rename from apps/website/src/app/angular/page.tsx
rename to apps/website/src/app/langgraph/page.tsx
index 59a88133..b922128e 100644
--- a/apps/website/src/app/angular/page.tsx
+++ b/apps/website/src/app/langgraph/page.tsx
@@ -8,17 +8,17 @@ import { BrowserFrame } from '../../components/ui/BrowserFrame';
import { FeatureBlock } from '../../components/landing/FeatureBlock';
import { WhitePaperBlock } from '../../components/landing/WhitePaperBlock';
import { FinalCTA } from '../../components/landing/FinalCTA';
-import { AngularCodeShowcase } from '../../components/landing/angular/AngularCodeShowcase';
+import { LangGraphCodeShowcase } from '../../components/landing/langgraph/LangGraphCodeShowcase';
import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
title: '@ngaf/langgraph — Agent UI for Angular',
description: SHORT_POSITIONING_DESCRIPTION,
- pathname: '/angular',
+ pathname: '/langgraph',
type: 'website',
});
-export default async function AngularPage() {
+export default async function LangGraphPage() {
return (
<>
{/* Hero */}
@@ -136,7 +136,7 @@ export class ChatComponent {
]}
cta={{ label: 'Read the streaming guide', href: '/docs/agent/api/agent' }}
visualLeft
- visual={}
+ visual={}
/>
diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx
index de07919f..823c7ddd 100644
--- a/apps/website/src/app/pricing/page.tsx
+++ b/apps/website/src/app/pricing/page.tsx
@@ -2,7 +2,6 @@ import { tokens } from '@ngaf/design-tokens';
import { Container } from '../../components/ui/Container';
import { Section } from '../../components/ui/Section';
import { Eyebrow } from '../../components/ui/Eyebrow';
-import { PricingGrid } from '../../components/pricing/PricingGrid';
import { CompareTable } from '../../components/pricing/CompareTable';
import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix';
import { PricingFAQ } from '../../components/pricing/PricingFAQ';
@@ -13,33 +12,15 @@ import { createPageMetadata } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
title: 'Pricing — Agent UI for Angular',
description:
- '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a Threadplane license. Other libraries remain MIT.',
+ '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a ThreadPlane Commercial license. Other libraries remain MIT.',
pathname: '/pricing',
type: 'website',
});
-function SmallNote({ children }: { children: React.ReactNode }) {
- return (
-
Pricing
@@ -52,47 +33,17 @@ export default function PricingPage() {
lineHeight: tokens.typography.h1.line,
color: tokens.colors.textPrimary,
margin: 0,
- marginBottom: 16,
letterSpacing: '-0.02em',
}}
>
- Pricing for production AI chat interfaces
+ Simple, transparent pricing
-
- @ngaf/chat is free for noncommercial use. Commercial production use requires a Threadplane license. Other libraries in the framework remain MIT.
-
-
-
-
-
-
- A license is required when @ngaf/chat is used in a commercial product, SaaS app, internal business tool, paid client project, or production application operated by or for a for-profit entity.
-
-
-
-
-
-
-
- Commercial evaluation is free for 30 days. A paid license is required before production deployment.
-
-
-
-
Compatibility
@@ -123,14 +74,6 @@ export default function PricingPage() {
-
-
-
- Because commercial use requires a license, @ngaf/chat is source-available rather than OSI open source. Threadplane keeps ecosystem packages (@ngaf/render, @ngaf/agent, @ngaf/langgraph, @ngaf/ag-ui, @ngaf/a2ui, @ngaf/licensing, @ngaf/telemetry, @ngaf/design-tokens) permissively MIT-licensed.
-
-
-
-
>
diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx
index c9c264d6..a6658e5e 100644
--- a/apps/website/src/app/solutions/[slug]/page.tsx
+++ b/apps/website/src/app/solutions/[slug]/page.tsx
@@ -22,7 +22,7 @@ interface PageProps {
}
const LIBRARY_HREF: Record = {
- Agent: '/angular',
+ Agent: '/langgraph',
Render: '/render',
Chat: '/chat',
};
diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx
index 1dd98470..cf2f5cb9 100644
--- a/apps/website/src/app/thanks/page.tsx
+++ b/apps/website/src/app/thanks/page.tsx
@@ -7,7 +7,7 @@ import { Button } from '../../components/ui/Button';
import { createPageMetadata } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
- title: 'Payment received — Threadplane',
+ title: 'Payment received — ThreadPlane',
description: 'Your @ngaf/chat license token will be emailed shortly.',
pathname: '/thanks',
type: 'website',
@@ -57,8 +57,8 @@ export default function ThanksPage() {
If you don't see the email within 10 minutes, check spam or contact us.