feat: org entitlement system (seat limits + quota:project)#2845
feat: org entitlement system (seat limits + quota:project)#2845omar-inkeep merged 26 commits intomainfrom
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add the Entitlements tag to TagToDescription and the docs icon map so the entitlements route passes the docs build validation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
DAL boundary lint requires all drizzle-orm imports to live in data-access/. Extracted DB queries from auth/entitlements.ts into data-access/runtime/entitlements.ts as dal* functions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- DB CHECK constraint enforces resource_type regex ^[a-z]+:[a-z][a-z0-9_]*$ preventing malformed entries (1-way door defense per D3) - Log orgId, role, bucket, currentCount, maxValue when enforcement blocks an action for production operability Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Add payment_required (402) error code for entitlement limit responses - Create entitlement-meta module (mirrors authz-meta pattern) - Extend createProtectedRoute with optional entitlement prop + x-entitlement - Create requireEntitlement middleware in agents-api - Add QUOTA_RESOURCE_TYPES constant (quota:project) - Wire quota:project entitlement on POST /projects route - Export resolveEntitlement and entitlement constants from @inkeep/agents-core Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Remove duplicate 0023_lazy_energizer (merge conflict leftover) and merge 0025 (table) + 0026 (CHECK constraint) into one migration. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
… description - Wire requireEntitlement on POST /project-full (create path) - Skip PUT upsert route — would falsely block updates at capacity - Update x-entitlement description to reflect conditional enforcement Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Replace plain Error with better-auth APIError('PAYMENT_REQUIRED') in
enforcePerRoleSeatLimit for consistent 402 responses with structured
body (code, resourceType, current, limit)
- Add handler-level entitlement check on PUT /project-full upsert route
only when creating a new project (isProjectCreate), skipping updates
- Add test verifying structured APIError body fields
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 72eb253 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Replace client-side useOrgEntitlements hook (direct fetch) with server action in lib/api/entitlements.ts using makeManagementApiRequest + cache(), matching the pattern used by projects/agents/tools. Entitlements are now fetched in the page's fetchData alongside other parallel requests. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- New /[tenantId]/billing route showing org resource usage - Displays seat usage (admin/member) and project quota with progress bars - Usage bars turn red when at capacity - Admin-only access (non-admins see permission error) - Shows "no limits configured" state when no entitlements exist - Added to sidebar nav between Members and Settings (CreditCard icon) - Loading skeleton for initial load - Fetches entitlements, org members, and projects in parallel Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…dback Add seat enforcement to invitation verify endpoint, expose seat limit status in verification response, and update UI to display capacity warnings. Fix test mocks for new resolveEntitlement/countSeatsByRole/getUserByEmail deps. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
TL;DR — Introduces a generic, extensible org entitlement system that enforces per-role seat limits ( Key changes
Summary | 55 files | 26 commits | base:
|
There was a problem hiding this comment.
Solid feature. The normalized org_entitlement table design, opt-in limits, and x-entitlement OpenAPI extension are well-considered. A few issues need attention — one correctness bug in the seat-counting logic, one inconsistency between the PR description and the implementation, and a duplicated entitlement check in the PUT upsert handler.
Claude Opus | 𝕏
| ): Promise<number> { | ||
| const isAdmin = roleMatchesAdminBucket(role); | ||
| const serviceAccountUserId = await dalGetServiceAccountUserId(db, orgId); | ||
| return dalCountMembersByRoleBucket(db, orgId, isAdmin, serviceAccountUserId); |
There was a problem hiding this comment.
countSeatsByRole calls dalCountMembersByRoleBucket which counts only active members. dalCountPendingInvitationsByRoleBucket is defined in the DAL but never called. This means enforcePerRoleSeatLimit does not count pending invitations, yet the PR description states "Pending invitations count toward seat limits."
This is a real gap: an org at capacity can have all pending invites accepted in parallel (the beforeAcceptInvitation hook checks members-only count, each sees room). Either:
- Add pending invitation count here:
return dalCountMembersByRoleBucket(...) + dalCountPendingInvitationsByRoleBucket(...) - Or update the PR description to document that pending invitations are intentionally excluded and accept the race window.
Which semantics are intended?
| ); | ||
|
|
||
| return result?.count ?? 0; | ||
| } |
There was a problem hiding this comment.
dalCountPendingInvitationsByRoleBucket is dead code — it's never called from anywhere in this PR. If pending invitations should count toward seat limits (per the PR description), wire it into countSeatsByRole. Otherwise, remove it to avoid confusion.
| if (isCreate) { | ||
| const { resolveEntitlement } = await import('@inkeep/agents-core'); | ||
| const limit = await resolveEntitlement(runDbClient, tenantId, QUOTA_RESOURCE_TYPES.PROJECT); | ||
| if (limit !== null) { | ||
| const current = await countProjectsInRuntime(runDbClient)({ tenantId }); | ||
| if (current >= limit) { | ||
| throw createApiError({ | ||
| code: 'payment_required', | ||
| message: `Project limit reached (${current}/${limit})`, | ||
| instance: c.req.path, | ||
| extensions: { | ||
| resourceType: QUOTA_RESOURCE_TYPES.PROJECT, | ||
| current, | ||
| limit, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This inline entitlement check in the PUT upsert handler duplicates the logic already encapsulated in requireEntitlement middleware (used by the POST route at line 106). The POST route uses the middleware pattern; the PUT upsert handler does a manual dynamic import + inline check.
The duplication exists because the PUT handler needs the isProjectCreate flag to decide whether to enforce the quota. Consider extracting a helper (e.g., enforceQuotaIfCreate(c, resourceType, countFn)) or using the middleware conditionally, so the enforcement logic lives in one place.
| let seatLimitReached: string | null = null; | ||
| try { | ||
| const role = invitation.role; | ||
| const isAdmin = role === 'admin' || role === 'owner'; | ||
| const resourceType = isAdmin ? 'seat:admin' : 'seat:member'; | ||
| const limit = await resolveEntitlement(runDbClient, invitation.organizationId, resourceType); | ||
| if (limit !== null) { | ||
| const current = await countSeatsByRole(runDbClient, invitation.organizationId, role); | ||
| if (current >= limit) { | ||
| const bucket = isAdmin ? 'Admin' : 'Member'; | ||
| seatLimitReached = `${bucket} seat limit reached (${current}/${limit})`; | ||
| } | ||
| } | ||
| } catch { | ||
| // Best-effort — don't block the invitation verification | ||
| } |
There was a problem hiding this comment.
The seat-limit check in the /verify endpoint is best-effort (swallowed on error), which is appropriate for a preview endpoint. However, it uses raw string literals 'seat:admin' / 'seat:member' instead of the SEAT_RESOURCE_TYPES constants imported by other files. Use the constants for consistency:
import { SEAT_RESOURCE_TYPES } from '@inkeep/agents-core';
const resourceType = isAdmin ? SEAT_RESOURCE_TYPES.ADMIN : SEAT_RESOURCE_TYPES.MEMBER;| const [entitlementsResult, orgResult, projectsResult] = await Promise.all([ | ||
| fetchEntitlements(tenantId).catch(() => [] as OrgEntitlement[]), | ||
| authClient.organization.getFullOrganization({ | ||
| query: { organizationId: tenantId, membersLimit: 300 }, |
There was a problem hiding this comment.
membersLimit: 300 is hardcoded. If seat entitlements sum to more than 300, the member list will be truncated and the seat counts on this page will be wrong. Consider either:
- Using the sum of seat entitlements (or
DEFAULT_MEMBERSHIP_LIMIT) as the limit - Or paginating / using a dedicated count endpoint instead of fetching all members
|
|
||
| const adminEntitlement = entitlements.find((e) => e.resourceType === SEAT_RESOURCE_TYPES.ADMIN); | ||
| const memberEntitlement = entitlements.find((e) => e.resourceType === SEAT_RESOURCE_TYPES.MEMBER); | ||
| const projectEntitlement = entitlements.find((e) => e.resourceType === 'quota:project'); |
There was a problem hiding this comment.
Uses raw string 'quota:project' instead of QUOTA_RESOURCE_TYPES.PROJECT (which is already imported via SEAT_RESOURCE_TYPES from @inkeep/agents-core/client-exports). Use the constant for consistency.
| const meta = getAuthzMeta(permission); | ||
| const entitlementMetaValue = entitlement ? getEntitlementMeta(entitlement) : undefined; | ||
|
|
||
| const middlewares = [permission, ...toArray(routeConfig.middleware)]; |
There was a problem hiding this comment.
Entitlement middleware is appended after the permission middleware (line 44), which is the correct ordering — authz before quota. Just confirming the intent: if a user lacks permission, they get 403 before the quota check runs. Good.
| useEffect(() => { | ||
| if (tenantId) { | ||
| fetchEntitlements(tenantId) | ||
| .then((entitlements) => setHasEntitlements(entitlements.length > 0)) | ||
| .catch(() => setHasEntitlements(false)); | ||
| } | ||
| }, [tenantId]); |
There was a problem hiding this comment.
The fetchEntitlements call runs on every mount of the sidebar. Since this is a 'use server' function wrapped in cache(), the React cache only deduplicates within a single render pass — it won't persist across navigations. This means every page navigation re-fetches entitlements. Consider lifting this to a context/provider or using SWR/React Query so the sidebar doesn't re-fetch on every route change.
| throw new APIError('PAYMENT_REQUIRED', { | ||
| message: `${bucket} seat limit reached (${current}/${limit})`, | ||
| code: 'ENTITLEMENT_LIMIT_REACHED', | ||
| resourceType, | ||
| current, | ||
| limit, | ||
| }); | ||
| } |
There was a problem hiding this comment.
enforcePerRoleSeatLimit throws APIError from better-auth/api with status 'PAYMENT_REQUIRED' (string). Meanwhile, requireEntitlement middleware throws HTTPException via createApiError({ code: 'payment_required' }) using the standard error utility. The two code paths produce different error shapes:
- better-auth hooks:
{ status: 'PAYMENT_REQUIRED', body: { code: 'ENTITLEMENT_LIMIT_REACHED', ... } } - API middleware: RFC 7807 problem details with
{ code: 'payment_required', ... }
The UI checks for error.code === 'ENTITLEMENT_LIMIT_REACHED' — verify this works for both paths. The better-auth client may surface the error differently than the Hono middleware error handler.
| "@inkeep/agents-manage-ui": patch | ||
| --- | ||
|
|
||
| Add per-role seat limit enforcement to invitations and members UI |
There was a problem hiding this comment.
The changeset message says "Add per-role seat limit enforcement to invitations and members UI" but the PR also adds the org_entitlement table, quota:project enforcement, a billing page, and an entitlements API endpoint. Consider a more complete message, e.g.:
Add org entitlement system with per-role seat limits, project quotas, and billing usage page
Ito Test Report ❌21 test cases ran. 2 failed, 19 passed. Across 21 validated scenarios, 19 passed and 2 failed, indicating the entitlement, quota, project-creation idempotency, invitation acceptance/security, and mobile invitation usability paths are mostly behaving correctly, including proper billing remediation at limits and expected uncapped behavior for no-entitlement tenants. The two key defects are a High-severity concurrency race that lets simultaneous member-to-admin promotions exceed admin seat caps and a Medium fail-closed regression where Billing stays visible on malformed tenant routes because stale async entitlement state is retained, both tied to changes in this PR. ❌ Failed (2)
|
There was a problem hiding this comment.
PR Review Summary
(2) Total Issues | Risk: Medium
This is a delta re-review scoped to changes since the last automated review (aee733553..20987d46a).
Delta Analysis
The delta consists of:
1dd963a42— Merge branch 'main' into feat/max-seats (brings in scheduler, skills, and security fixes)20987d46a— Remove duplicate migration file (0028_condemned_agent_zero.sql)
Entitlement-specific changes: Migration file cleanup only. The entitlement table DDL now lives in 0029_burly_satana.sql after resolving conflicts with main.
🟠⚠️ Major (1) 🟠⚠️
🟠 1) auth.ts:375-386 Role update outside entitlement lock scope creates TOCTOU race
Issue: enforcePerRoleSeatLimit acquires the lock, checks counts, then releases the lock before changeOrgRole executes. Concurrent role changes can both pass the check before either commits.
Why: Ito test confirmed: "Concurrent member-to-admin promotions both succeeded (HTTP 200), producing 3 admins while the entitlement limit was 2." This undermines seat-limit enforcement.
Fix: Move the role mutation inside the locked transaction scope (see inline comment for options).
Refs:
Inline Comments:
- 🟠 Major:
auth.ts:386Role update outside entitlement lock scope
🟡 Minor (1) 🟡
🟡 1) app-sidebar.tsx:57-63 Stale entitlement state on tenant navigation
Issue: When tenant changes, hasEntitlements retains previous value while new fetch is in-flight. Malformed tenant paths keep stale truthy state.
Why: Ito test observed Billing nav remaining visible after navigating to tampered tenant paths.
Fix: Reset state immediately on tenant change, add cancellation (see inline comment).
Refs:
Inline Comments:
- 🟡 Minor:
app-sidebar.tsx:63Stale entitlement state on tenant navigation
🕐 Pending Recommendations (2)
These were raised in prior reviews and remain non-blocking considerations on unchanged code:
- 💭
billing/page.tsx:66—(orgResult.data as any).serviceAccountUserIduses unsafe type assertion - 💭
billing/page.tsx:105-107— Silentnullreturn when no entitlements (could show informative empty state)
💡 APPROVE WITH SUGGESTIONS
Summary: The entitlement system design remains solid. The delta since last review is primarily a merge commit and migration cleanup. However, the Ito tests have surfaced a real TOCTOU race in seat-limit enforcement for role changes that allows concurrent promotions to exceed the admin seat cap. This should be fixed by moving the role mutation inside the entitlement lock's transaction scope. The sidebar stale-state issue is minor but worth addressing for fail-closed UX.
Discarded (0)
No findings discarded in this delta review.
Reviewers (1)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
orchestrator |
2 | 1 | 0 | 0 | 2 | 2 | 0 |
| Total | 2 | 1 | 0 | 0 | 2 | 2 | 0 |
Note: This is a targeted delta review. The major finding was surfaced by Ito E2E test failures rather than sub-reviewer dispatch, as the delta contained no new feature code requiring specialized review.
| const newBucketIsAdmin = roleMatchesAdminBucket(targetRole); | ||
| if (oldBucketIsAdmin !== newBucketIsAdmin) { | ||
| await enforcePerRoleSeatLimit(config.dbClient, org.id, targetRole); | ||
| } |
There was a problem hiding this comment.
🟠 MAJOR: Role update outside entitlement lock scope creates TOCTOU race
Issue: enforcePerRoleSeatLimit acquires the entitlement lock and checks seat counts, but the actual role mutation (changeOrgRole at line 390) happens after the lock is released. Two concurrent role changes can both pass the seat check before either commits the actual role update.
Why: The Ito test "Concurrent promotions can exceed admin seat cap" confirmed this race: both concurrent member-to-admin promotions succeeded (HTTP 200), producing 3 admins while the entitlement limit was 2. This undermines billing/authorization guardrails.
Fix: The role mutation needs to happen inside the locked transaction. Consider one of:
- Pass a callback to the lock that performs both the check and the SpiceDB update:
await withEntitlementLock(db, org.id, resourceType, async (limit, tx) => {
if (limit !== null) {
const current = await countSeatsByRole(tx, org.id, targetRole);
if (current >= limit) throw new APIError('PAYMENT_REQUIRED', ...);
}
// Role update inside the lock
await changeOrgRole({ tenantId: org.id, userId: member.userId, oldRole, newRole: targetRole });
});-
Use a database-level constraint (if SpiceDB update can be made idempotent and retried on rollback)
-
Document as known limitation if the traffic pattern makes concurrent role promotions unlikely
Refs:
- Ito test failure — "Concurrent promotions can exceed admin seat cap"
- entitlement-lock.ts
| .then((entitlements) => setHasEntitlements(entitlements.length > 0)) | ||
| .catch(() => setHasEntitlements(false)); | ||
| } | ||
| }, [tenantId]); |
There was a problem hiding this comment.
🟡 Minor: Stale entitlement state on tenant navigation
Issue: When tenantId changes, hasEntitlements retains its previous value until the new fetch completes. If the new fetch fails or the tenant path is malformed, the previous truthy state persists, keeping the Billing nav visible incorrectly.
Why: The Ito test "Entitlements fetch path tampering fails closed" observed that navigating to a tampered tenant path (e.g., /%2F/members) kept Billing visible. This weakens fail-closed UX expectations for tenant boundary handling.
Fix: Reset state immediately when tenant changes, and add stale-response protection:
useEffect(() => {
setHasEntitlements(false); // Reset immediately
if (!tenantId) return;
let cancelled = false;
fetchEntitlements(tenantId)
.then((entitlements) => {
if (!cancelled) setHasEntitlements(entitlements.length > 0);
})
.catch(() => {
if (!cancelled) setHasEntitlements(false);
});
return () => { cancelled = true; };
}, [tenantId]);Refs:
- Ito test failure — "Entitlements fetch path tampering fails closed"
Ito Test Report ✅19 test cases ran. 19 passed. The unified run passed all 19 of 19 test cases with zero failures, confirming expected entitlement, quota, billing, member-seat, and invitation behaviors in local non-production execution with no code-supported product defects observed. Key findings were that tenant entitlement APIs enforced strict auth and isolation (including no unauth/cross-tenant leakage), project creation and full upsert paths correctly enforced quota boundaries (402 at limit, no duplicate creates, updates allowed for existing IDs), billing UI/route access and usage rendering behaved correctly across admin/non-admin and zero-entitlement states, seat-saturation blocked admin invites/promotions with billing guidance, and invitation flows remained correct, mobile-usable, and secure against ID/email tampering, external returnUrl hijacking, and refresh/back-forward inconsistency. ✅ Passed (19)Commit: Tell us how we did: Give Ito Feedback |
There was a problem hiding this comment.
PR Review Summary
(0) New Issues | Risk: Low
This is a delta re-review scoped to changes since the last automated review (20987d46a..72eb25363).
✅ All Prior Issues Addressed
The single commit (72eb25363 feedback) resolves all outstanding issues from prior reviews:
| Prior Issue | Resolution |
|---|---|
| 🟠 TOCTOU race in role updates (#pullrequestreview-4036956322) | ✅ The doRoleChange callback is now executed inside withEntitlementLock via the new onceVerified parameter. Role mutations happen atomically with the seat limit check. |
| 🟡 Stale entitlement state (#pullrequestreview-4036956322) | ✅ app-sidebar.tsx now resets hasEntitlements to false immediately on tenant change and uses proper effect cleanup with a cancelled flag. |
| 💭 Unsafe type assertion (#pullrequestreview-4031625873) | ✅ Removed as any cast — now uses orgResult.data.serviceAccountUserId directly with proper typing. |
| 💭 Silent null return (#pullrequestreview-4031625873) | ✅ Billing page now shows an informative Card with "No entitlements configured" message instead of blank content. |
Implementation Quality
TOCTOU fix is correct:
// entitlements.ts
export async function enforcePerRoleSeatLimit(
db: AgentsRunDatabaseClient,
orgId: string,
role: string,
onceVerified?: () => Promise<void> // ← NEW: callback executed inside lock
): Promise<void> {
await withEntitlementLock(db, orgId, resourceType, async (limit, tx) => {
// Check limit inside lock
if (limit !== null) {
const current = await countSeatsByRole(tx, orgId, role);
if (current >= limit) throw new APIError('PAYMENT_REQUIRED', ...);
}
// Execute role change inside lock (atomic with check)
if (onceVerified) {
await onceVerified();
}
});
}Caller integration is clean:
// auth.ts - beforeUpdateMemberRole hook
const doRoleChange = async () => {
await changeOrgRole({ tenantId: org.id, userId: member.userId, oldRole, newRole: targetRole });
};
if (oldBucketIsAdmin !== newBucketIsAdmin) {
// Cross-bucket change: check + execute inside lock
await enforcePerRoleSeatLimit(config.dbClient, org.id, targetRole, doRoleChange);
} else {
// Same bucket: no lock needed
await doRoleChange();
}Ito Tests
The latest Ito test run confirms 19/19 tests passing, including:
- ✅ Concurrent role promotion race condition — now properly blocked
- ✅ Stale sidebar entitlement state — now resets correctly
- ✅ Non-admin billing access denial — shows proper "Access denied"
- ✅ Empty entitlements billing page — shows informative message
✅ APPROVE
Summary: This commit cleanly addresses all prior feedback. The TOCTOU race in seat-limit enforcement for role changes is now fixed by executing the changeOrgRole mutation inside the withEntitlementLock transaction via a callback pattern. The sidebar stale-state and billing UX issues are also resolved. All 19 Ito E2E tests pass. Ship it! 🚀
Reviewers (1)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
orchestrator |
4 | 0 | 0 | 0 | 0 | 0 | 0 |
| Total | 4 | 0 | 0 | 0 | 0 | 0 | 0 |
Note: This is a delta review. All 4 items analyzed were fixes for prior feedback — no new issues found.
|
🔎💬 Inkeep AI search and chat service is syncing content for source 'Inkeep Agent Framework Docs' |
Document the new org entitlement system for seat limits introduced in PR #2845. Adds a Seat Limits subsection under Inviting Team Members covering: - seat:admin and seat:member limit types - Pending invitations counting toward limits - 402 Payment Required error behavior - Self-hosted default (no limits enforced)
Ito Test Report ❌19 test cases ran. 1 failed, 18 passed. Across 19 total test cases, 18 passed and 1 failed: entitlement retrieval, Billing visibility/authorization, quota and seat-capacity gating, invitation verification/acceptance (existing and new users), tamper/open-redirect defenses, rapid interaction handling, and mobile responsiveness all behaved as expected, with fail-closed behavior and no sensitive data leakage observed. The key finding is a High-severity real defect in concurrent project creation where two near-simultaneous POST /manage/tenants/:tenantId/projects requests can both succeed and exceed quota because the entitlement check counts usage via the global DB client instead of the lock transaction context, breaking entitlement and billing guarantees. ❌ Failed (1)
|
| Category | Summary | Screenshot |
|---|---|---|
| Adversarial | Rapid repeated submissions at quota did not create duplicate projects or bypass limits. | |
| Adversarial | Missing email, mismatched email/id, and stale invitation id all failed safely with stable invalid-invitation screens and no visible sensitive data leakage, including after back/forward/reload. | |
| Adversarial | Open-redirect attempts via returnUrl are blocked by same-origin validation and safe fallback redirect handling. | |
| Adversarial | Rapid alternating Accept/Decline interactions produced one terminal accepted outcome without duplicate processing artifacts. | |
| Authz | Non-admin deep-link to billing is blocked with access denied and no entitlement usage values exposed. | |
| Edge | At quota, create was blocked with 402 payment_required contract; remediation action is implemented in UI. | |
| Edge | With member seats saturated (0/0), invite submission was blocked and showed entitlement-specific remediation with a See usage link to billing. | |
| Edge | In authenticated invited-user state under saturated seat entitlement, page showed limit alert, disabled Accept button, and enabled Decline button. | |
| Mobile | Prior blocked outcome was a harness artifact; mobile billing showed no horizontal overflow and invitation submit entered disabled/loading state as expected. | |
| Happy-path | Authenticated admin request to /manage/tenants/default/entitlements returned HTTP 200 with valid entitlements JSON payload. |
|
| Happy-path | Project creation below quota succeeded and navigated to the new project's agents page. | |
| Happy-path | Verify endpoint returned required contract keys for valid invitation links; existing invite reported userExists=true and new invite reported userExists=false. | |
| Screen | Billing nav visibility correctly toggled: visible with entitlements, hidden on simulated 500 and empty entitlement responses. | |
| Screen | Billing usage cards render correctly for admin; the "Need more?" card is intentionally informational and non-clickable by design. | |
| Screen | Inviting [email protected] as Member succeeded with 1 successful result, copy-link control, and invitation-sent status messaging. | |
| Screen | Cross-bucket role change into a full admin bucket remained blocked, with the member role preserved and seat-limit messaging shown. | |
| Screen | Existing invited user saw login path (no signup), signed in, and invitation flow redirected safely to an internal app route. | |
| Screen | After reseeding to a truly new email, invitation page showed signup (not login); account creation and join flow completed successfully with safe internal redirect. |
Commit: 72eb253
Tell us how we did: Give Ito Feedback
* [US-001] Add orgEntitlement table to runtime DB schema Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-002] Implement entitlement resolution and enforcement module Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-003] Add unit tests for entitlement functions Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-004] Wire entitlement enforcement into better-auth hooks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-006] Add entitlements API endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-007] Add seat usage display to members page Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-008] Add seat-aware invite dialog and role selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [US-006] Register Entitlements tag in OpenAPI spec and docs generator Add the Entitlements tag to TagToDescription and the docs icon map so the entitlements route passes the docs build validation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: move entitlement DB queries to data-access layer DAL boundary lint requires all drizzle-orm imports to live in data-access/. Extracted DB queries from auth/entitlements.ts into data-access/runtime/entitlements.ts as dal* functions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add CHECK constraint on resource_type + enforcement logging - DB CHECK constraint enforces resource_type regex ^[a-z]+:[a-z][a-z0-9_]*$ preventing malformed entries (1-way door defense per D3) - Log orgId, role, bucket, currentCount, maxValue when enforcement blocks an action for production operability Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add requireEntitlement middleware + quota:project - Add payment_required (402) error code for entitlement limit responses - Create entitlement-meta module (mirrors authz-meta pattern) - Extend createProtectedRoute with optional entitlement prop + x-entitlement - Create requireEntitlement middleware in agents-api - Add QUOTA_RESOURCE_TYPES constant (quota:project) - Wire quota:project entitlement on POST /projects route - Export resolveEntitlement and entitlement constants from @inkeep/agents-core Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: consolidate entitlement migrations into single 0025 Remove duplicate 0023_lazy_energizer (merge conflict leftover) and merge 0025 (table) + 0026 (CHECK constraint) into one migration. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add quota:project to POST /project-full + clarify x-entitlement description - Wire requireEntitlement on POST /project-full (create path) - Skip PUT upsert route — would falsely block updates at capacity - Update x-entitlement description to reflect conditional enforcement Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: use APIError for seat enforcement + guard PUT upsert create path - Replace plain Error with better-auth APIError('PAYMENT_REQUIRED') in enforcePerRoleSeatLimit for consistent 402 responses with structured body (code, resourceType, current, limit) - Add handler-level entitlement check on PUT /project-full upsert route only when creating a new project (isProjectCreate), skipping updates - Add test verifying structured APIError body fields Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: use server action pattern for entitlements fetch Replace client-side useOrgEntitlements hook (direct fetch) with server action in lib/api/entitlements.ts using makeManagementApiRequest + cache(), matching the pattern used by projects/agents/tools. Entitlements are now fetched in the page's fetchData alongside other parallel requests. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add Billing & Usage page - New /[tenantId]/billing route showing org resource usage - Displays seat usage (admin/member) and project quota with progress bars - Usage bars turn red when at capacity - Admin-only access (non-admins see permission error) - Shows "no limits configured" state when no entitlements exist - Added to sidebar nav between Members and Settings (CreditCard icon) - Loading skeleton for initial load - Fetches entitlements, org members, and projects in parallel Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: enforce per-role seat limits on invitations + UI seat limit feedback Add seat enforcement to invitation verify endpoint, expose seat limit status in verification response, and update UI to display capacity warnings. Fix test mocks for new resolveEntitlement/countSeatsByRole/getUserByEmail deps. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: add changeset for seat limit enforcement Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * restore deleted sql file * fix test * fix: TOCTOU race in quota enforcement, add middleware tests, preserve entitlement extensions - Add withEntitlementLock using SELECT ... FOR UPDATE to serialize concurrent quota checks against the entitlement row - Wrap requireEntitlement middleware check + handler in lock scope - Move projectFull.ts inline quota check into lock-guarded callback - Fix errorHandler stripping entitlement extension fields (resourceType, current, limit) from 402 responses - Add requireEntitlement middleware test coverage (5 tests) - Replace all hardcoded membersLimit: 300 with DEFAULT_MEMBERSHIP_LIMIT - Fix test assertion casing (admin → Admin) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Fix billing page error handling * remove sql file * feedback --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Summary
org_entitlementtable for per-org, per-resource-type limits (extensible to any entitlement type)seat:admin,seat:member) in better-auth hooks (invite, add, accept, role change)membershipLimit: 300with dynamic callbackrequireEntitlementmiddleware pattern for API route-level quota enforcementquota:projecton project creation routes (POST + PUT upsert create path)GET /manage/tenants/:tenantId/entitlementsAPI endpoint^[a-z]+:[a-z][a-z0-9_]*$x-entitlementOpenAPI extension on entitlement-gated routescode,resourceType,current,limit)Key design decisions
seat:admin,quota:project) — categorized, queryable, regex-enforcedseat:admincounts both owner and admin rolesTest plan
seat:adminandseat:memberentitlement rows, verify invite/add/role-change blocked at capacityquota:projectrow, verify project creation blocked at capacity via UI and SDKmembershipLimitreturns sum of seat entitlements (or 300 default)pnpm checkpasses🤖 Generated with Claude Code