AUTH4D-39 plan entitlements

Plan entitlements gate developer management actions only. End-player authentication (sign-in, token, refresh, device and guest flows) never reads billing state, so an unpaid or over-limit project keeps serving its players.

Effective entitlements (packages/tenancy getEntitlements / createEntitlementService)

The service reads the tenant’s newest active or past_due subscription through @auth4/billing, and catalog limits come from PLAN_CATALOG. Every query filters by the tenant id.

Subscription state Effective plan source
none (or only pending / canceled / expired) free free
active, before current_period_end subscribed plan subscription
active with cancel_at_period_end, after period end free lapsed
past_due (or unrenewed active) before period end + 7d subscribed plan grace_period
past_due (or unrenewed active) after period end + 7d free lapsed

The grace period is BILLING_GRACE_PERIOD_DAYS (7). When a past_due subscription has no period end, updated_at is the start of the grace period. The service is read-only. Moving a subscription to expired remains a billing state-machine transition (expireSubscription) run by the billing jobs.

Application limit

POST /management/tenants/{tenantId}/clients and re-enabling a disabled application (PUT/PATCH .../clients/{clientId} with enabled: true) need a free slot under entitlements.maxApplications. Only enabled applications count, so disabling (DELETE) an application frees a slot. A request over the limit returns:

HTTP/1.1 402 Payment Required
Cache-Control: no-store, max-age=0

{
  "error": {
    "code": "plan_limit_exceeded",
    "message": "The current plan limit has been reached. Upgrade the plan to continue.",
    "request_id": "req_...",
    "details": [{ "field": "maxApplications", "issue": "The free plan allows 2 enabled applications." }]
  }
}

The body conforms to ManagementErrorResponseSchema. Authorization runs first, so callers without access still receive 403 forbidden or 403 insufficient_role. The existing hard cap of 50 rows per project (409 client_limit_reached) is unchanged. The limit is also checked again inside the write batch, so concurrent creates cannot exceed it. A denied request writes no client, idempotency or audit row. Applications above the limit after a downgrade stay enabled and keep working, but no further applications can be enabled.

Usage reporting

GET /management/tenants/{tenantId}/usage (scope tenant:read; project roles owner, admin, viewer):

{
  "usage": {
    "tenantId": "ten_...",
    "planId": "free",
    "source": "free",
    "graceEndsAt": null,
    "period": { "start": "2026-10-01T00:00:00.000Z", "end": "2026-11-01T00:00:00.000Z" },
    "monthlyActiveUsers": { "count": 2, "limit": 1000, "overLimit": false },
    "applications": { "count": 2, "limit": 2, "overLimit": false }
  }
}

MAU is the number of distinct end users in the tenant whose session was created or refreshed in the current UTC calendar month (session_indexes.last_seen_at). Sign-in and refresh already write those rows, so MAU counting adds no work or failure mode to authentication. MAU over the limit is reported only and never enforced. Hosts can pass onUsageReport to createEntitlementService to forward reports to metrics or notifications. The callback is called before the report returns but is not awaited, and its errors are ignored. The API uses it for the AUTH4D-309 owner alerts at 80% and 100% of the limit (see auth4d-309.md).

Source: docs/api-spec/auth4d-39.md