Skip to content

feat: org entitlement system (seat limits + quota:project)#2845

Merged
omar-inkeep merged 26 commits intomainfrom
feat/max-seats
Mar 31, 2026
Merged

feat: org entitlement system (seat limits + quota:project)#2845
omar-inkeep merged 26 commits intomainfrom
feat/max-seats

Conversation

@omar-inkeep
Copy link
Copy Markdown
Contributor

Summary

  • Introduce org_entitlement table for per-org, per-resource-type limits (extensible to any entitlement type)
  • Enforce per-role seat limits (seat:admin, seat:member) in better-auth hooks (invite, add, accept, role change)
  • Replace hardcoded membershipLimit: 300 with dynamic callback
  • Add requireEntitlement middleware pattern for API route-level quota enforcement
  • Wire quota:project on project creation routes (POST + PUT upsert create path)
  • Surface seat usage in manage UI (members page, invite dialog, role selector)
  • Add GET /manage/tenants/:tenantId/entitlements API endpoint
  • 20 unit tests for entitlement functions
  • DB CHECK constraint enforcing resource type naming convention ^[a-z]+:[a-z][a-z0-9_]*$
  • x-entitlement OpenAPI extension on entitlement-gated routes
  • 402 Payment Required with structured error body (code, resourceType, current, limit)

Key design decisions

  • Normalized table over columns/JSONB — unlimited entitlement types without migrations
  • Colon-namespaced resource types (seat:admin, quota:project) — categorized, queryable, regex-enforced
  • Opt-in limits — no entitlement row = no limit enforced. Cloud inserts rows; self-hosted uncapped by default
  • Pending invitations count toward seat limits — prevents over-inviting
  • Owner shares admin bucketseat:admin counts both owner and admin roles

Test plan

  • Insert seat:admin and seat:member entitlement rows, verify invite/add/role-change blocked at capacity
  • Verify no limits enforced when no entitlement rows exist (uncapped)
  • Insert quota:project row, verify project creation blocked at capacity via UI and SDK
  • Verify membershipLimit returns sum of seat entitlements (or 300 default)
  • Verify UI shows seat usage on members page, disables roles at capacity
  • pnpm check passes

🤖 Generated with Claude Code

omar-inkeep and others added 14 commits March 25, 2026 08:30
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]>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 31, 2026 1:28pm
agents-docs Ready Ready Preview, Comment Mar 31, 2026 1:28pm
agents-manage-ui Ready Ready Preview, Comment Mar 31, 2026 1:28pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 26, 2026

🦋 Changeset detected

Latest commit: 72eb253

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-core Patch
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch

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

omar-inkeep and others added 3 commits March 27, 2026 09:33
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]>
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 30, 2026

TL;DR — Introduces a generic, extensible org entitlement system that enforces per-role seat limits (seat:admin, seat:member) and resource quotas (quota:project). Limits are opt-in — no entitlement rows means no cap — making this safe for self-hosted deployments while enabling cloud billing controls. The full stack is wired: schema, DAL, better-auth hooks, API middleware, OpenAPI metadata, manage UI feedback, and a new Billing page. All quota checks are serialized via SELECT ... FOR UPDATE to prevent TOCTOU races under concurrent requests.

Key changes

  • Add org_entitlement table with colon-namespaced resource types — normalized schema with a CHECK constraint enforcing the ^[a-z]+:[a-z][a-z0-9_]*$ naming convention, unique per (organizationId, resourceType)
  • Implement entitlement resolution and seat-counting DAL — new data-access/runtime/entitlements.ts with functions for resolving limits, counting members by role bucket (excluding service accounts), and summing seat entitlements
  • Enforce seat limits in better-auth organization hooksbeforeCreateInvitation, beforeAddMember, beforeAcceptInvitation, and beforeUpdateMemberRole all call enforcePerRoleSeatLimit; replaces hardcoded membershipLimit: 300 with a dynamic callback via resolveTotalMembershipLimit
  • Run SpiceDB role changes inside the entitlement lockbeforeUpdateMemberRole now wraps the SpiceDB changeOrgRole call inside enforcePerRoleSeatLimit via an onceVerified callback, ensuring the role mutation and the limit check are atomic under the same SELECT ... FOR UPDATE lock
  • Add withEntitlementLock for TOCTOU-safe quota enforcement — wraps entitlement resolution + count check in a transaction with SELECT ... FOR UPDATE on the entitlement row, serializing concurrent quota checks
  • Add requireEntitlement middleware for API route-level quota enforcement — generic middleware that acquires the entitlement lock, runs a caller-supplied countFn, and returns 402 with structured error body; wired to project creation routes via quota:project
  • Guard PUT /project-full upsert create path with withEntitlementLock — the PUT upsert's inline create branch now wraps the entire flow in the entitlement lock, closing a bypass where PUT could create projects without quota checks
  • Extend createProtectedRoute with x-entitlement OpenAPI extension — entitlement-gated routes now emit metadata in the OpenAPI spec via entitlement-meta.ts WeakMap pattern
  • Add GET /manage/tenants/:tenantId/entitlements API endpoint — lists all entitlement rows for an org, exposed in OpenAPI under the new Entitlements tag
  • Add payment_required (402) error code and forward extensions in error handler — new variant in ErrorCode enum; errorHandler now spreads extension fields (resourceType, current, limit) into the 402 response body
  • Surface seat limits in invite dialog, role selector, members table, and project formENTITLEMENT_LIMIT_REACHED errors show contextual messages with links to the billing page for admins; project creation errors with payment_required also surface a "See usage" action
  • Add Billing & Usage page with error/empty states — new /{tenantId}/billing route showing progress bars for seat and project usage; renders an informative card when no entitlements are configured; conditionally visible in the sidebar only when entitlements exist
  • Refactor accept-invitation page into component modules — extracted AcceptDecline, AuthMethodPicker, ExternalAuthButtons, LoginForm, SignupForm, InvitationLayout, and InvitationSuccess from the monolithic page; AcceptDecline disables the accept button when seat limit is reached
  • Return seatLimitReached and userExists from invitation verify endpoint — the verify route now returns these fields so the accept-invitation UI can proactively disable accept and route to the correct auth flow
  • Fix sidebar entitlements fetch race condition — the useEffect in AppSidebar now uses a cancellation flag and cleanup function to prevent state updates on unmounted components
  • Replace all hardcoded membersLimit: 300 with DEFAULT_MEMBERSHIP_LIMIT — UI pages and auth config now reference the shared constant
  • Remove stale migration file — deleted superseded 0028_condemned_agent_zero.sql in favor of 0029_burly_satana.sql
  • Add unit tests for entitlement functions and middleware — covers resolveEntitlement, countSeatsByRole, enforcePerRoleSeatLimit, resolveTotalMembershipLimit, roleMatchesAdminBucket, and 5 requireEntitlement middleware tests

Summary | 55 files | 26 commits | base: mainfeat/max-seats


org_entitlement table and data access layer

Before: No concept of per-org resource limits; membership capped at a hardcoded 300.
After: A normalized org_entitlement table stores (organizationId, resourceType, maxValue) tuples with a regex CHECK constraint on resource_type. A full DAL layer provides dalResolveEntitlement, dalCountMembersByRoleBucket, and dalSumSeatEntitlements.

The colon-namespaced resource type convention (seat:admin, seat:member, quota:project) allows querying by category with LIKE 'seat:%' while keeping the table flat. No entitlement row = no limit enforced, so self-hosted deployments remain uncapped by default.

How does seat counting handle service accounts? Service accounts (identified via organization.serviceAccountUserId) are excluded from member counts in dalCountMembersByRoleBucket and added back as +1 to the total membership limit in resolveTotalMembershipLimit.

runtime-schema.ts · entitlements.ts (DAL) · entitlement-constants.ts · 0029_burly_satana.sql


TOCTOU-safe quota enforcement with withEntitlementLock

Before: Quota checks used a read-then-check pattern — two concurrent requests could both pass the limit check before either committed, allowing over-provisioning.
After: withEntitlementLock opens a transaction, acquires a row-level lock via SELECT ... FOR UPDATE on the entitlement row, then runs the caller's check+action callback. Concurrent callers block until the lock is released.

This lock is used in three places: the requireEntitlement middleware (for quota:project on POST /projects), the projectFull.ts PUT upsert create path (which wraps the entire create transaction inside the lock), and enforcePerRoleSeatLimit in the better-auth hooks (for seat:admin / seat:member).

entitlement-lock.ts · requireEntitlement.ts · projectFull.ts · entitlements.ts (core)


Per-role seat enforcement in better-auth hooks

Before: Organization hooks only synced SpiceDB state on member/invitation events; membership capped at a static 300.
After: enforcePerRoleSeatLimit is called in beforeCreateInvitation, beforeAddMember, beforeAcceptInvitation, and beforeUpdateMemberRole (only when the role bucket changes). The membershipLimit callback now dynamically sums seat:* entitlements via resolveTotalMembershipLimit, falling back to DEFAULT_MEMBERSHIP_LIMIT (300) when no entitlements are configured.

Enforcement throws APIError('PAYMENT_REQUIRED') with structured code, resourceType, current, limit fields. The beforeUpdateMemberRole hook now passes the SpiceDB changeOrgRole call as an onceVerified callback to enforcePerRoleSeatLimit, so the role mutation executes atomically inside the same SELECT ... FOR UPDATE lock — preventing a race where a concurrent role change could slip in between the limit check and the actual mutation. The invitation verify endpoint now also returns seatLimitReached and userExists so the accept-invitation UI can proactively block acceptance and route to the correct auth flow.

auth.ts · entitlements.ts (core) · invitations.ts


requireEntitlement middleware and quota:project

Before: No generic mechanism for route-level resource quota enforcement.
After: requireEntitlement({ resourceType, countFn, label }) creates Hono middleware that acquires the entitlement lock, calls the count function, and returns 402 if at capacity. Wired to POST /projects, POST /project-full, and the PUT upsert create path in projectFull.ts.

The middleware registers x-entitlement metadata on the route via registerEntitlementMeta, which createProtectedRoute picks up and injects into the OpenAPI spec. The errorHandler now destructures known fields and spreads remaining extensions into the response body, ensuring resourceType, current, and limit reach the client in the 402 response.

How does the error handler preserve entitlement fields? The errorHandler destructures known fields (code, title, status, detail, instance, error) and spreads the rest into the response body. This ensures extension fields like resourceType, current, and limit from createApiError({ extensions: {...} }) are forwarded to the client in the 402 response.

requireEntitlement.ts · errorHandler.ts · projects.ts · create-protected-route.ts


Manage UI: seat-aware feedback and Billing page

Before: No visibility into seat usage or entitlement limits in the UI.
After: The invite dialog, role selector, members table, and project form all surface ENTITLEMENT_LIMIT_REACHED / payment_required errors with "See usage" links (admins) or "Contact your admin" messages. A new /billing page renders progress bars for seat:admin, seat:member, and quota:project usage with proper error/forbidden/empty states. The sidebar conditionally shows the Billing nav item only when entitlements exist, with a proper cleanup function on the fetch effect to prevent state updates on unmounted components.

The accept-invitation page was refactored from a single monolithic component into seven focused modules. AcceptDecline disables the accept button and shows an alert when the seat limit is reached, using the new seatLimitReached field from the verify endpoint. AuthMethodPicker uses the new userExists field to route returning users to login and new users to signup. The billing page now renders an informative card with "No entitlements configured" when no entitlements exist, instead of returning null. All hardcoded membersLimit: 300 values across UI pages now reference DEFAULT_MEMBERSHIP_LIMIT.

billing/page.tsx · invite-member-dialog.tsx · org-role-selector.tsx · app-sidebar.tsx

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

): Promise<number> {
const isAdmin = roleMatchesAdminBucket(role);
const serviceAccountUserId = await dalGetServiceAccountUserId(db, orgId);
return dalCountMembersByRoleBucket(db, orgId, isAdmin, serviceAccountUserId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Add pending invitation count here: return dalCountMembersByRoleBucket(...) + dalCountPendingInvitationsByRoleBucket(...)
  2. 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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +413 to +431
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,
},
});
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +94 to +109
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +62 to +68
useEffect(() => {
if (tenantId) {
fetchEntitlements(tenantId)
.then((entitlements) => setHasEntitlements(entitlements.length > 0))
.catch(() => setHasEntitlements(false));
}
}, [tenantId]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +58 to +65
throw new APIError('PAYMENT_REQUIRED', {
message: `${bucket} seat limit reached (${current}/${limit})`,
code: 'ENTITLEMENT_LIMIT_REACHED',
resourceType,
current,
limit,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 30, 2026

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)
Category Summary Screenshot
Adversarial ⚠️ Concurrent member-to-admin promotions both succeeded and exceeded the admin seat cap. ADV-3
Adversarial 🟠 Billing nav remains visible on malformed tenant paths instead of failing closed when entitlement fetch becomes invalid. ADV-6
⚠️ Concurrent promotions can exceed admin seat cap
  • What failed: Both concurrent promotions succeeded (HTTP 200), producing 3 admins while the entitlement limit remained 2; expected behavior is one success and one entitlement-limit rejection.
  • Impact: Admin seat limits can be bypassed under concurrent updates, allowing organizations to exceed purchased capacity. This undermines billing and authorization guardrails tied to role-cap enforcement.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Configure seat:admin to 2 with one existing admin and two member users.
    2. Start two near-simultaneous member-to-admin role updates for different users.
    3. Observe both requests return success.
    4. Check final role counts against the entitlement limit.
  • Stub / mock context: A local non-production auth-session bypass was temporarily added in agents-api/src/createApp.ts so admin flows could run despite environment login issues. The concurrent role-update calls and entitlement checks themselves still exercised real application logic and database state.
  • Code analysis: I reviewed the auth organization hook path and entitlement enforcement implementation in packages/agents-core/src/auth. beforeUpdateMemberRole performs a pre-check via enforcePerRoleSeatLimit, but the actual role mutation is not performed inside the same locked transaction scope as the count check, so two concurrent requests can both pass the check before either write is committed.
  • Why this is likely a bug: The seat-cap check is lock-protected only during pre-validation, but the member role write happens outside that critical section, enabling a classic check-then-act race where concurrent promotions over-allocate admin seats.

Relevant code:

packages/agents-core/src/auth/auth.ts (lines 375-386)

beforeUpdateMemberRole: async ({ member, organization: org, newRole }) => {
  const { roleMatchesAdminBucket, enforcePerRoleSeatLimit } = await import(
    './entitlements'
  );
  const oldRole = member.role as OrgRole;
  const targetRole = newRole as OrgRole;

  const oldBucketIsAdmin = roleMatchesAdminBucket(oldRole);
  const newBucketIsAdmin = roleMatchesAdminBucket(targetRole);
  if (oldBucketIsAdmin !== newBucketIsAdmin) {
    await enforcePerRoleSeatLimit(config.dbClient, org.id, targetRole);
  }

packages/agents-core/src/auth/entitlements.ts (lines 50-68)

await withEntitlementLock(db, orgId, resourceType, async (limit, tx) => {
  if (limit === null) return;

  const current = await countSeatsByRole(tx, orgId, role);
  if (current >= limit) {
    const bucket = roleMatchesAdminBucket(role) ? 'Admin' : 'Member';
    logger.info(
      { orgId, role, bucket, currentCount: current, maxValue: limit, action: 'enforce' },
      `${bucket} seat limit reached (${current}/${limit})`
    );
    throw new APIError('PAYMENT_REQUIRED', {
      message: `${bucket} seat limit reached (${current}/${limit})`,
      code: 'ENTITLEMENT_LIMIT_REACHED',
      resourceType,
      current,
      limit,
    });
  }
});

packages/agents-core/src/auth/entitlement-lock.ts (lines 11-22)

return db.transaction(async (tx) => {
  const rows = await tx
    .select({ maxValue: orgEntitlement.maxValue })
    .from(orgEntitlement)
    .where(
      and(eq(orgEntitlement.organizationId, orgId), eq(orgEntitlement.resourceType, resourceType))
    )
    .for('update');

  const limit = rows.length === 0 ? null : rows[0].maxValue;
  return fn(limit, tx as unknown as AgentsRunDatabaseClient);
});
🟠 Entitlements fetch path tampering fails closed
  • What failed: The Billing nav item stays visible after tenant-path tampering, while expected behavior is fail-closed hiding of Billing when tenant entitlement resolution is invalid.
  • Impact: Users can see entitlement-gated Billing navigation in malformed-tenant states, which weakens fail-closed UX/security expectations for tenant boundary handling. This can mislead users into a broken privileged route path.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Open an entitled tenant members route such as /tenant_entitled/members.
    2. Navigate to a tampered tenant route such as /tenant_entitled%2Fmal/members or /%2F/members.
    3. Observe that Billing remains visible in org navigation instead of being hidden.
  • Stub / mock context: The run used local development session bypass and seeded tenant fixtures to exercise malformed tenant routes without production identity systems; no route-level response mocking was used for this navigation check.
  • Code analysis: I reviewed the sidebar entitlement gating flow and found an async state race in the tenant-change effect. The component preserves prior hasEntitlements state while a new request is in flight and does not guard late responses by tenant identity, so stale truthy state can keep Billing visible on tampered routes.
  • Why this is likely a bug: The entitlement-gated nav decision depends on mutable async state without cancellation or stale-response protection, allowing a previous tenant's successful fetch to incorrectly control visibility after path tampering.

Relevant code:

agents-manage-ui/src/components/sidebar-nav/app-sidebar.tsx (lines 55-63)

const [hasEntitlements, setHasEntitlements] = useState(false);

useEffect(() => {
  if (tenantId) {
    fetchEntitlements(tenantId)
      .then((entitlements) => setHasEntitlements(entitlements.length > 0))
      .catch(() => setHasEntitlements(false));
  }
}, [tenantId]);

agents-manage-ui/src/components/sidebar-nav/app-sidebar.tsx (lines 100-108)

...(hasEntitlements
  ? [
      {
        title: STATIC_LABELS.billing,
        url: `/${tenantId}/billing`,
        icon: CreditCard,
      },
    ]
  : []),
✅ Passed (19)
Category Summary Screenshot
Adversarial Rapid double-submit remained idempotent and produced exactly one project. ADV-1
Adversarial Rapid repeated invite submissions did not bypass limits; all over-capacity invites were rejected. ADV-2
Adversarial Authenticated direct accept API call was rejected with entitlement/payment-required style error (402), and invited user was not added to organization membership. ADV-4
Adversarial Appending external returnUrl did not produce open redirect; post-accept navigation stayed on same-origin tenant route. ADV-5
Edge Quota-boundary create behavior was verified as correctly blocked with billing remediation. EDGE-2
Edge Project-full upsert create path enforced quota while existing-project update remained allowed. EDGE-3
Edge Billing stayed hidden for tenant_unlimited, and inviting one member succeeded with no seat-limit/payment-required error. EDGE-4
Edge Seat-limit enforcement blocked the extra invite and the remediation link correctly routed to billing. EDGE-5
Edge Cross-bucket Member-to-Admin promotion is designed to be seat-enforced and remain blocked when admin seats are full. EDGE-6
Edge Seat-limited invite displayed entitlement warning, kept Accept disabled, and left Decline actionable. EDGE-7
Edge Opening /accept-invitation/ without email query produced deterministic invalid-link ErrorContent and Go home link, with no auth/login/signup controls rendered. EDGE-8
Logic Project creation under available quota succeeded with one new project and success navigation. LOGIC-1
Logic Invite-under-capacity behavior was re-run successfully; adding a member completed with a success result as expected. LOGIC-2
Logic Same-bucket Admin/Owner role transitions are intentionally not seat-blocked, matching the entitlement guard logic. LOGIC-3
Mobile On iPhone 12 viewport, the invitation signup flow stayed usable through portrait→landscape→portrait rotation with no horizontal overflow, visible primary action, and preserved form state (email/full name/password). MOBILE-1
Happy-path Billing nav visibility logic is implemented correctly and behaves as expected for entitlement presence checks. ROUTE-1
Happy-path Entitlements endpoint contract returned expected resourceType/maxValue schema for the entitled tenant scenario. ROUTE-3
Happy-path Logged-out invite opened with signup-first UI (full name + password). After valid signup+accept, flow reached success and redirected to same-origin tenant route. ROUTE-4
Happy-path Invite URL rendered login-only branch with disabled email and forgot-password link; existing user sign-in and invitation acceptance reached success. ROUTE-5

Commit: aee7335

View Full Run


Tell us how we did: Give Ito Feedback

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 1dd963a42 — Merge branch 'main' into feat/max-seats (brings in scheduler, skills, and security fixes)
  2. 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:386 Role 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:63 Stale entitlement state on tenant navigation

🕐 Pending Recommendations (2)

These were raised in prior reviews and remain non-blocking considerations on unchanged code:


💡 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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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:

  1. 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 });
});
  1. Use a database-level constraint (if SpiceDB update can be made idempotent and retried on rollback)

  2. Document as known limitation if the traffic pattern makes concurrent role promotions unlikely

Refs:

.then((entitlements) => setHasEntitlements(entitlements.length > 0))
.catch(() => setHasEntitlements(false));
}
}, [tenantId]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 31, 2026

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)
Category Summary Screenshot
Adversarial Endpoint authorization boundaries held: authenticated access succeeded; logged-out and tenant-mismatched access were denied with no payload exposure. ADV-1
Adversarial Invitation verify rejected mismatched id/email and malformed probes with stable 404 responses without metadata leakage. ADV-2
Adversarial Invitation acceptance ignored external returnUrl and redirected to an internal tenant route. ADV-3
Adversarial Refresh and back/forward after acceptance remained stable, and consumed invitation verify returned not_found. ADV-4
Edge Re-execution confirmed exact-quota project creation returns HTTP 402 payment_required with entitlement metadata, and billing CTA behavior is wired in code. EDGE-1
Edge With one slot remaining, rapid double-submit created only one project (qa-edge2-202603311244) with no duplicate rows. EDGE-2
Edge At exact quota, full-project upsert with non-existent id returned 402 payment_required and no new project row was created. EDGE-3
Edge Non-admin direct access to /default/billing returned an Access denied 403 state, including after back/forward navigation. EDGE-4
Edge Zero-entitlement tenant hid Billing navigation and loaded /billing without crashing or showing usage metrics. EDGE-5
Edge Promoting a Member to Admin with a saturated admin seat bucket returned an entitlement failure and the role remained Member after refresh. EDGE-6
Edge Method-matrix behavior matched verify payloads: existing invite rendered login path, new invite rendered signup path with actionable controls, and no-method invite rendered non-progressing dead-end guidance. EDGE-7
Edge At 390x844 viewport, invitation flow remained usable with no horizontal overflow and accessible controls through sign-in/acceptance; flow completed and redirected to projects successfully. EDGE-8
Happy-path Authenticated entitlement request returned valid tenant-scoped rows and schema; anonymous/cross-tenant requests were denied. ROUTE-1
Happy-path Created qa-route2-202603311242 under quota from UI and confirmed it appears in project list. ROUTE-2
Happy-path At exact quota, full-project upsert for existing project id qa-route2-202603311242 returned 200 and persisted updated fields. ROUTE-3
Happy-path Billing navigation was present for entitlement-enabled tenant and absent for zero-entitlement tenant, matching conditional visibility rules. ROUTE-4
Happy-path Billing usage cards rendered with populated values, and Projects usage matched the Projects list count. ROUTE-5
Happy-path Inviting an Admin while the admin seat bucket was saturated showed an entitlement-specific failure row and a working See usage link to billing. ROUTE-6
Happy-path Verify API returned valid=true, userExists=true, allowedAuthMethods and org/role metadata for existing invite; UI showed login-focused branch for existing user, signup branch for new user, and blocking no-method guidance correctly. ROUTE-7

Commit: 20987d4

View Full Run


Tell us how we did: Give Ito Feedback

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot deleted a comment from claude Bot Mar 31, 2026
@omar-inkeep omar-inkeep added this pull request to the merge queue Mar 31, 2026
Merged via the queue into main with commit abc3b5d Mar 31, 2026
23 of 24 checks passed
@omar-inkeep omar-inkeep deleted the feat/max-seats branch March 31, 2026 13:51
@github-actions
Copy link
Copy Markdown
Contributor

🔎💬 Inkeep AI search and chat service is syncing content for source 'Inkeep Agent Framework Docs'

inkeep Bot added a commit that referenced this pull request Mar 31, 2026
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)
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 31, 2026

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 ⚠️ Concurrent project quota enforcement can be bypassed under race conditions. ADV-5
⚠️ Concurrent project creation can exceed quota under race conditions
  • What failed: Both requests can pass the pre-check and create projects, allowing usage to exceed the configured quota; expected behavior is exactly one success and one entitlement rejection.
  • Impact: Organizations can exceed paid project limits during concurrent writes, which breaks entitlement guarantees and billing controls. This can create unauthorized over-allocation until manual cleanup.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Set quota:project for the tenant so current usage is one below the limit.
    2. Open two independent authenticated sessions that can create projects.
    3. Submit project creation in both sessions nearly simultaneously and observe both succeed.
  • Stub / mock context: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code analysis: I reviewed the entitlement middleware, lock helper, and project create route; the lock callback receives a transaction handle, but the quota count runs through the global runtime DB client instead of that transaction, so the read is not serialized with the lock-guarded check and create path.
  • Why this is likely a bug: The code explicitly introduces a lock transaction API but does not use its transaction context for the quota count, which leaves a race window that can violate the limit under concurrent creates.

Relevant code:

agents-api/src/middleware/requireEntitlement.ts (lines 31-53)

await withEntitlementLock(runDbClient, tenantId, resourceType, async (limit, _tx) => {
  if (limit === null) {
    await next();
    return;
  }

  const current = await countFn(tenantId);

  if (current >= limit) {
    throw createApiError({
      code: 'payment_required',
      message: `${displayLabel} limit reached (${current}/${limit})`,
    });
  }

  await next();
});

packages/agents-core/src/auth/entitlement-lock.ts (lines 11-22)

return db.transaction(async (tx) => {
  const rows = await tx
    .select({ maxValue: orgEntitlement.maxValue })
    .from(orgEntitlement)
    .where(
      and(eq(orgEntitlement.organizationId, orgId), eq(orgEntitlement.resourceType, resourceType))
    )
    .for('update');

  const limit = rows.length === 0 ? null : rows[0].maxValue;
  return fn(limit, tx as unknown as AgentsRunDatabaseClient);
});

agents-api/src/domains/manage/routes/projects.ts (lines 168-173)

permission: requirePermission({ project: ['create'] }),
entitlement: requireEntitlement({
  resourceType: QUOTA_RESOURCE_TYPES.PROJECT,
  countFn: (tenantId) => countProjectsInRuntime(runDbClient)({ tenantId }),
  label: 'Project',
}),
✅ Passed (18)
Category Summary Screenshot
Adversarial Rapid repeated submissions at quota did not create duplicate projects or bypass limits. ADV-1
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. ADV-2
Adversarial Open-redirect attempts via returnUrl are blocked by same-origin validation and safe fallback redirect handling. ADV-3
Adversarial Rapid alternating Accept/Decline interactions produced one terminal accepted outcome without duplicate processing artifacts. ADV-4
Authz Non-admin deep-link to billing is blocked with access denied and no entitlement usage values exposed. AUTHZ-1
Edge At quota, create was blocked with 402 payment_required contract; remediation action is implemented in UI. EDGE-1
Edge With member seats saturated (0/0), invite submission was blocked and showed entitlement-specific remediation with a See usage link to billing. EDGE-2
Edge In authenticated invited-user state under saturated seat entitlement, page showed limit alert, disabled Accept button, and enabled Decline button. EDGE-3
Mobile Prior blocked outcome was a harness artifact; mobile billing showed no horizontal overflow and invitation submit entered disabled/loading state as expected. MOBILE-1
Happy-path Authenticated admin request to /manage/tenants/default/entitlements returned HTTP 200 with valid entitlements JSON payload. ROUTE-1
Happy-path Project creation below quota succeeded and navigated to the new project's agents page. ROUTE-2
Happy-path Verify endpoint returned required contract keys for valid invitation links; existing invite reported userExists=true and new invite reported userExists=false. ROUTE-3
Screen Billing nav visibility correctly toggled: visible with entitlements, hidden on simulated 500 and empty entitlement responses. SCREEN-1
Screen Billing usage cards render correctly for admin; the "Need more?" card is intentionally informational and non-clickable by design. SCREEN-2
Screen Inviting [email protected] as Member succeeded with 1 successful result, copy-link control, and invitation-sent status messaging. SCREEN-3
Screen Cross-bucket role change into a full admin bucket remained blocked, with the member role preserved and seat-limit messaging shown. SCREEN-4
Screen Existing invited user saw login path (no signup), signed in, and invitation flow redirected safely to an internal app route. SCREEN-5
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. SCREEN-6

Commit: 72eb253

View Full Run


Tell us how we did: Give Ito Feedback

tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
* [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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant