Skip to content
Open
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
40 changes: 25 additions & 15 deletions docs/licensing.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,21 @@ systems.
| ------------ | -------------------------------------------------------------------- |
| `cloud` | Always (billing-driven; license ignored). |
| `community` | Never. |
| `enterprise` | **Only with a valid, non-expired, signature-verified license.** Individual features additionally require their flag in the token's `features[]`. |
| `enterprise` | **Only with a valid, signature-verified license.** A license still unlocks everything for 7 days after `exp` (a "grace" state); past that it degrades to community. Individual features additionally require their flag in the token's `features[]`. |

The server-side seam is `packages/business/src/user/entitlements.ts`:

```ts
export const hasEnterpriseFeatures = async (): Promise<boolean> => {
if (isCloud()) return true
if (!isEnterprise()) return false
return (await getLicenseStatus()).state === "valid"
if (!(isCloud() || isEnterprise())) return false
const license = await getLicenseStatus()
return isLicenseEntitled(license.state) // state is "valid" or "grace"
}
```

On a missing, invalid, or expired key the app **degrades to the community
feature set** — it never crashes and never destroys data.
On a missing, invalid, or lapsed (past the grace window) license the app
**degrades to the community feature set** — it never crashes and never
destroys data.

> The client-side `hasEnterpriseFeatures` in `apps/builder/src/env.ts` is an
> **edition-only, cosmetic** check (it hides sidebar items). It is *not* a
Expand Down Expand Up @@ -101,7 +102,8 @@ Compact JWS, header `{ alg: "EdDSA", kid, typ: "JWT" }`. Payload
Verification (`service.ts`) rejects non-`EdDSA` algorithms *before* verifying
(algorithm-confusion guard), rejects unknown `kid`s, enforces `iss`/`exp`/`nbf`
via `jose`, and validates the payload shape with zod. An **expired** token still
surfaces its claims (state `"expired"`) so operators can see what lapsed.
surfaces its claims — state `"grace"` for the first 7 days past `exp`, then
`"expired"` — so operators can see what lapsed.

**Tier must match the running edition.** `NEXT_PUBLIC_EDITION=cloud` deployments
only accept `tier: "cloud"` licenses; `NEXT_PUBLIC_EDITION=enterprise` deployments
Expand All @@ -119,7 +121,7 @@ never verifies a license, so this rule doesn't apply there.
the TTL, it verifies `LICENSE_KEY`, reads the stored token for the same
`licenseId`, and selects the newer usable token:

1. a valid token beats an expired token;
1. a higher-ranked state wins (`valid` > `grace` > `expired` > `invalid`/`missing`);
2. otherwise, later `iat` wins;
3. a later `exp` breaks an `iat` tie.

Expand All @@ -133,9 +135,10 @@ installations.
Accessors:

- `getLicenseStatus()` / `getLicense()` — the full `LicenseStatus`.
- `hasFeature(flag)` — `true` only when the license is valid **and** lists the
flag.
- `getLimit(key)` — the numeric limit (`null` = unlimited) when valid.
- `hasFeature(flag)` — `true` when the license is `"valid"` or `"grace"` **and**
lists the flag.
- `getLimit(key)` — the numeric limit (`null` = unlimited) when `"valid"` or
`"grace"`.

## Issuing a license (vendor)

Expand Down Expand Up @@ -201,10 +204,17 @@ if (max !== null && currentCount >= max) {
roll it back. Acceptable for an explicitly offline/air-gapped model — the
signature makes `features`/`limits`/`exp` unforgeable, so only date
manipulation is possible (low value). `nbf` guards pre-dating.
- **Hard expiry.** Offline enterprise-license `exp` remains a hard cutoff with
clear messaging. The cloud trial path now has a degraded grace window instead:
an expired workspace is read/delete-only with a persistent banner, and its
channels are torn down at trial+7d.
- **7-day grace, not a hard cutoff.** An expired enterprise license keeps full
entitlement for `LICENSE_GRACE_PERIOD_DAYS` (7) past its `exp` (state
`"grace"`); only after that window does the deployment degrade to the
community feature set. The window is a source constant, not an env var —
an operator-tunable grace length would be an unbounded free-enterprise
switch in an MIT codebase — and is anchored to the signed `exp` claim, not
to "time since last successful refresh", so an unreachable vendor endpoint
never disables a license that is still paid for. The cloud trial path has
its own, separate degraded grace window: an expired trial workspace is
read/delete-only with a persistent banner, and its channels are torn down
at trial+7d.
- **No revocation.** Pure offline verification cannot revoke an issued token
before its `exp`. If online revocation is ever needed, it would be added in
`../aha.chat-enterprise` as an optional online check layered on top — the
Expand Down
3 changes: 2 additions & 1 deletion integrations/google-calendar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"@chatbotx.io/sdk": "workspace:*",
"google-auth-library": "^10.6.2",
"googleapis": "^171.4.0",
"universal-error-normalizer": "^1.0.0"
"universal-error-normalizer": "^1.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@chatbotx.io/typescript-config": "workspace:*",
Expand Down
35 changes: 35 additions & 0 deletions packages/auth/__tests__/generic-oauth-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ type GenericOAuthPlugin = {
id: string
config: Array<{
discoveryUrl?: string
mapProfileToUser?: (profile: {
family_name?: unknown
given_name?: unknown
name?: unknown
preferred_username?: unknown
}) => { name?: string }
pkce: boolean
redirectURI: string
scopes: string[]
Expand Down Expand Up @@ -150,6 +156,35 @@ describe("generic OAuth configuration", () => {
})
})

test("uses only string generic OAuth profile claims for display names", async () => {
vi.stubEnv("AUTH_OAUTH_ENABLED", "true")
vi.stubEnv("AUTH_OAUTH_CLIENT_ID", "test-client")
vi.stubEnv("AUTH_OAUTH_CLIENT_SECRET", "test-secret")
vi.stubEnv("AUTH_OAUTH_AUTH_URL", "https://idp.example.com/authorize")
vi.stubEnv("AUTH_OAUTH_TOKEN_URL", "https://idp.example.com/token")

const mapProfileToUser = getGenericOAuthPlugin(await getAuthConfig())
?.config[0].mapProfileToUser

expect(
mapProfileToUser?.({
name: { unexpected: "object" },
preferred_username: "acme-admin",
}),
).toEqual({ name: "acme-admin" })
expect(
mapProfileToUser?.({ family_name: "Lovelace", given_name: "Ada" }),
).toEqual({ name: "Ada Lovelace" })
expect(
mapProfileToUser?.({
name: " ",
preferred_username: "\t",
given_name: "",
family_name: " ",
}),
).toEqual({ name: undefined })
})

test("uses default scopes when scopes are omitted", async () => {
vi.stubEnv("AUTH_OAUTH_ENABLED", "true")
vi.stubEnv("AUTH_OAUTH_CLIENT_ID", "test-client")
Expand Down
31 changes: 22 additions & 9 deletions packages/auth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,15 +833,28 @@ export function createAuth(config: AuthConfig) {
`/api/auth/oauth2/callback/${GENERIC_OAUTH_PROVIDER_ID}`,
new URL(getBrokerUrl()).origin,
).toString(),
mapProfileToUser: (profile) => ({
name:
profile.name ??
profile.preferred_username ??
([profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ") ||
undefined),
}),
mapProfileToUser: (profile) => {
const profileString = (value: unknown) => {
if (typeof value !== "string") {
return
}

const trimmed = value.trim()
return trimmed || undefined
}
const fullName = [profile.given_name, profile.family_name]
.map(profileString)
.filter((part): part is string => part !== undefined)
.join(" ")
const name = [
profile.name,
profile.preferred_username,
fullName,
]
.map(profileString)
.find((value): value is string => value !== undefined)
return { name }
},
},
],
}),
Expand Down
9 changes: 9 additions & 0 deletions packages/business/__tests__/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock("../src/keys", () => ({

vi.mock("../src/enterprise/license/service", () => ({
getLicenseStatus: mocks.getLicenseStatus,
isLicenseEntitled: (state: string) => state === "valid" || state === "grace",
}))

describe("enterprise entitlements", () => {
Expand Down Expand Up @@ -63,6 +64,14 @@ describe("enterprise entitlements", () => {

await expect(hasEnterpriseFeatures()).resolves.toBe(false)
})

test("treats a grace-period license as enabled", async () => {
mocks.isEnterprise.mockReturnValue(true)
mocks.getLicenseStatus.mockResolvedValue({ state: "grace" })
const { hasEnterpriseFeatures } = await import("../src/user/entitlements")

await expect(hasEnterpriseFeatures()).resolves.toBe(true)
})
})

describe("assertEnterpriseFeatures", () => {
Expand Down
107 changes: 104 additions & 3 deletions packages/business/__tests__/license.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe("enterprise license verification", () => {

test("surfaces signed claims for an expired license", async () => {
const token = await signLicense({
exp: NOW_SECONDS - 2 * DAY_SECONDS,
exp: NOW_SECONDS - 9 * DAY_SECONDS,
})

const status = await verifyLicenseToken(token, keyMap, { currentDate: NOW })
Expand All @@ -129,11 +129,39 @@ describe("enterprise license verification", () => {
licenseId: "lic_test_123",
customerName: "Acme Corp",
keyId: KID,
daysRemaining: -2,
daysRemaining: -9,
})
expect(status.error).toContain("exp")
})

test("resolves to grace state within the 7-day window after expiry", async () => {
const token = await signLicense({
exp: NOW_SECONDS - 3 * DAY_SECONDS,
})

const status = await verifyLicenseToken(token, keyMap, { currentDate: NOW })

expect(status).toMatchObject({
state: "grace",
daysRemaining: -3,
features: ["sso", "customBranding"],
graceEndsAt: new Date(
(NOW_SECONDS + 4 * DAY_SECONDS) * 1000,
).toISOString(),
})
})

test("treats exactly 7 days past expiry as expired (boundary)", async () => {
const token = await signLicense({
exp: NOW_SECONDS - 7 * DAY_SECONDS,
})

const status = await verifyLicenseToken(token, keyMap, { currentDate: NOW })

expect(status.state).toBe("expired")
expect(status.graceEndsAt).toBe(NOW.toISOString())
})

test("rejects a tampered payload", async () => {
const token = tamperPayload(await signLicense())

Expand Down Expand Up @@ -311,6 +339,52 @@ describe("enterprise license verification", () => {
expect(await hasFeature("auditLog")).toBe(false)
})

test("keeps enterprise entitlements while inside the grace window", async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
process.env.LICENSE_KEY = await signLicense({
exp: NOW_SECONDS - 3 * DAY_SECONDS,
})
vi.resetModules()
vi.doMock("../src/enterprise/license/public-keys", () => ({
LICENSE_ISSUER,
LICENSE_PUBLIC_KEYS: keyMap,
}))
mockLicenseRepository()
const { getLicenseStatus, getLimit, hasFeature } = await import(
"../src/enterprise/license/service"
)

const status = await getLicenseStatus()

expect(status.state).toBe("grace")
expect(await hasFeature("sso")).toBe(true)
expect(await getLimit("maxWorkspaces")).toBe(7)
})

test("drops entitlements once the grace window has elapsed", async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
process.env.LICENSE_KEY = await signLicense({
exp: NOW_SECONDS - 9 * DAY_SECONDS,
})
vi.resetModules()
vi.doMock("../src/enterprise/license/public-keys", () => ({
LICENSE_ISSUER,
LICENSE_PUBLIC_KEYS: keyMap,
}))
mockLicenseRepository()
const { getLicenseStatus, getLimit, hasFeature } = await import(
"../src/enterprise/license/service"
)

const status = await getLicenseStatus()

expect(status.state).toBe("expired")
expect(await hasFeature("sso")).toBe(false)
expect(await getLimit("maxWorkspaces")).toBeUndefined()
})

test("prefers a valid stored token over an expired LICENSE_KEY", async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
Expand Down Expand Up @@ -363,7 +437,34 @@ describe("enterprise license verification", () => {
)

await expect(getLicenseStatus()).resolves.toMatchObject({
state: "expired",
state: "grace",
})
})

test("prefers a grace license over a lapsed one for the same licenseId", async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const envToken = await signLicense({
exp: NOW_SECONDS - 9 * DAY_SECONDS,
iat: NOW_SECONDS - 60,
})
const storedToken = await signLicense({
exp: NOW_SECONDS - 3 * DAY_SECONDS,
iat: NOW_SECONDS - 30,
})
process.env.LICENSE_KEY = envToken
vi.resetModules()
vi.doMock("../src/enterprise/license/public-keys", () => ({
LICENSE_ISSUER,
LICENSE_PUBLIC_KEYS: keyMap,
}))
mockLicenseRepository(storedToken)
const { getLicenseStatus } = await import(
"../src/enterprise/license/service"
)

await expect(getLicenseStatus()).resolves.toMatchObject({
state: "grace",
})
})

Expand Down
21 changes: 20 additions & 1 deletion packages/business/__tests__/license.startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe("assertLicenseAtStartup", () => {
expect(exitSpy).not.toHaveBeenCalled()
})

test("resolves with a warning (no exit) for an expired enterprise license", async () => {
test("resolves with a warning (no exit) for a license inside its grace window", async () => {
setEdition("enterprise")
process.env.LICENSE_KEY = await signLicense({
exp: NOW_SECONDS - 2 * DAY_SECONDS,
Expand All @@ -153,6 +153,25 @@ describe("assertLicenseAtStartup", () => {
vi.useRealTimers()
})

test("resolves with a warning (no exit) for a license past its grace window", async () => {
setEdition("enterprise")
process.env.LICENSE_KEY = await signLicense({
exp: NOW_SECONDS - 9 * DAY_SECONDS,
})
vi.resetModules()
mockPublicKeys()
vi.useFakeTimers()
vi.setSystemTime(NOW)
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)
const assertLicenseAtStartup = await importAssertLicenseAtStartup()

await expect(assertLicenseAtStartup()).resolves.toBeUndefined()
expect(exitSpy).not.toHaveBeenCalled()
vi.useRealTimers()
})

test("never verifies or exits for community edition", async () => {
setEdition("community")
vi.resetModules()
Expand Down
16 changes: 16 additions & 0 deletions packages/business/src/enterprise/license/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ export const refreshLicense = async (): Promise<LicenseRefreshOutcome> => {
return { status: "skipped", reason: current.state }
}

if (current.state === "grace") {
logger.warn(
{
licenseId: current.licenseId,
expiresAt: current.expiresAt,
graceEndsAt: current.graceEndsAt,
},
"license: expired and running on its grace window; enterprise features stop at graceEndsAt unless a renewal arrives",
)
} else if (current.state === "expired") {
logger.warn(
{ licenseId: current.licenseId, expiresAt: current.expiresAt },
"license: grace window elapsed; enterprise features are disabled until a renewal is issued",
)
}

let response: Response
try {
response = await fetch(LICENSE_REFRESH_URL, {
Expand Down
Loading
Loading