Skip to content

fix(identity): harden permission system, resolve test failures, and i… - #45

Merged
pramodnarayana merged 13 commits into
developmentfrom
fix/tenant-provisioning-hook
Feb 5, 2026
Merged

pramodnarayana merged 13 commits into
developmentfrom
fix/tenant-provisioning-hook

Conversation

@pramodnarayana

@pramodnarayana pramodnarayana commented Feb 4, 2026 •

Copy link
Copy Markdown
Owner

…mprove auth coverage

Summary by CodeRabbit

  • New Features

    • Organization context (ID, name, slug) surfaces in user sessions and UI
    • Tenant details page with editable general settings and save flow
    • Session refresh API exposed; automatic tenant provisioning during login
  • Bug Fixes

    • Permission checks now throw authorization errors on denial
  • Improvements

    • System tenants hidden from standard tenant lists; admin views scoped to system tenant
  • Tests/Docs

    • Expanded test coverage and new identity plan/tenant strategy documentation

@coderabbitai

coderabbitai Bot commented Feb 4, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@pramodnarayana has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 26 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR wires tenant/provider context through auth (server + adapter + DI), enriches sessions with organization fields, adds tenant read/update endpoints and validations, implements tenant-aware client hydration/provisioning with refresh/login changes, introduces isSystem tenants in schema/types, and updates numerous tests and UI hooks/layouts.

Changes

Cohort / File(s) Summary
Auth Session & Context
apps/api/src/modules/identity/auth/auth.service.ts, apps/web/src/shared/lib/auth/types.ts, apps/web/src/shared/lib/auth/AuthProvider.tsx, apps/web/src/shared/lib/auth/AuthProvider.spec.tsx
Enriched session/user now includes organizationId, organizationName, organizationSlug; login signature changed to accept { accessToken, user }; added refreshSession and tenant-aware hydration/provisioning with retry flow; tests added/updated.
Permissions Guard & Tests
apps/api/src/modules/identity/auth/permissions.guard.ts, apps/api/src/modules/identity/auth/permissions.guard.spec.ts
Guard now throws ForbiddenException for missing/insufficient permissions and returns true on success; tests updated to expect exceptions; reflector marked readonly.
Auth Adapter & Tenant Provisioning
packages/identity/src/adapters/better-auth.adapter.ts, packages/identity/src/adapters/better-auth.adapter.spec.ts
BetterAuthAdapter gains an ITenantProvider dependency, adds a typed API accessor, registers tenant-provisioning plugin calls after auth flows, and hardens response validation; constructor signature updated and tests adjusted.
DI & Identity Module
packages/identity/src/identity.module.ts, packages/identity/src/identity.module.spec.ts, packages/identity/src/constants.ts
IdentityModule register/registerAsync now inject and pass TENANT_PROVIDER/ITenantProvider into the auth provider factory; new exports (IdentityModuleOptions, IDENTITY_OPTIONS) and tests updated for tenant wiring.
Tenant Provider Interface & Adapters
packages/identity/src/interfaces/tenant-provider.interface.ts, packages/identity/src/adapters/drizzle-tenant.adapter.ts, packages/identity/src/adapters/drizzle-tenant.adapter.spec.ts
Added findOneForUser(userId, tenantId?) to ITenantProvider and DrizzleTenantAdapter; tenant mapping now includes isSystem; tests updated.
Tenants API & Validation
apps/api/src/modules/identity/tenants/tenants.controller.ts, apps/api/src/modules/identity/tenants/tenants.validation.ts, apps/api/src/modules/identity/tenants/tenants.controller.spec.ts
Added GET /tenants/:id and PATCH /tenants/:id/details endpoints with permission guards; introduced UpdateTenantSchema/UpdateTenantDto; tests for findOne/update flows and NotFound behavior.
Web Organization Hook & Layout
apps/web/src/modules/identity/hooks/useOrganization.ts, apps/web/src/app/layouts/TenantLayout.tsx, apps/web/src/app/layouts/TenantLayout.spec.tsx
New useOrganization(id?) React Query hook and Organization type; TenantLayout calls hook (derives orgName with fallbacks) and tests refactored to AuthContext + MemoryRouter.
Tenant UI & Types
apps/web/src/modules/identity/tenants/types.ts, apps/web/src/modules/identity/tenants/TenantList.tsx, apps/web/src/modules/tenants/pages/TenantListPage.tsx, apps/web/src/modules/identity/tenants/TenantList.spec.tsx
Added isSystem to tenant types; TenantList now filters out system tenants; UI mapping includes isSystem; tests adjusted.
Schema & System Tenant Scope
packages/identity/src/schema.ts, packages/identity/src/interfaces/types.ts, apps/api/src/modules/identity/system-admin/system-admin.controller.ts, apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts
Added non-null isSystem column (default false) to organization table and Tenant.isSystem type; system-admin list scoped to SYSTEM_TENANT_ID; tests updated.
E2E Seed & Test Infra
apps/api/src/db/reset-e2e.ts, apps/api/vitest.config.mts, apps/web/vitest.config.ts, various *.spec.tsx files
New DB reset/seed script for E2E (RBAC, users, orgs, memberships) and excluded from coverage; broadened vitest excludes; many test updates and new provider test suites added.
Access Control & Notification Tests
apps/web/src/app/providers/access-control-provider.spec.ts, apps/web/src/app/providers/notification-provider.spec.ts
New, extensive unit tests for access-control normalization/permission checks and notification-provider toast behavior.
Minor / Typings Cleanup
packages/identity/src/adapters/drizzle-permission.adapter.ts, packages/identity/src/adapters/drizzle-user.adapter.ts, packages/identity/src/interfaces/*.ts, apps/web/src/shared/components/layout/types.ts
Privileged-role normalization, multiple value→type-only import conversions, added organizationId?: string to AppUser, and small typing/refactor updates.
Docs
docs/architecture/flexible_identity_strategy.md
New architecture document describing plan-driven Tenant/Plan model, CapabilityService, guards, frontend adaptations, and migration strategy.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AuthProvider
    participant authClient
    participant apiClient
    participant TenantProvider

    Client->>AuthProvider: login(credentials)
    AuthProvider->>authClient: signIn / receive AuthResult
    AuthProvider->>AuthProvider: login(data) → refreshSession()
    AuthProvider->>authClient: getSession()
    authClient-->>AuthProvider: Session (may include organizationId)
    alt session has organizationId
        AuthProvider->>Client: set hydrated user with org fields
    else session missing organizationId
        AuthProvider->>apiClient: GET /tenants?userId
        apiClient-->>AuthProvider: tenants[]
        alt tenants found
            AuthProvider->>TenantProvider: (select latest) findOneForUser
            TenantProvider-->>AuthProvider: tenant
            AuthProvider->>Client: set user with organizationId/name/slug
        else no tenants
            AuthProvider->>TenantProvider: provisionTenantForUser(userId)
            TenantProvider-->>AuthProvider: new tenant
            AuthProvider->>apiClient: GET /tenants?userId (retry)
            apiClient-->>AuthProvider: tenants with new tenant
            AuthProvider->>Client: set user with new organization
        end
    end
Loading
sequenceDiagram
    participant Browser
    participant TenantLayout
    participant useOrganization
    participant apiClient
    participant API

    Browser->>TenantLayout: render (with auth user)
    TenantLayout->>useOrganization: useOrganization(user?.organizationId)
    useOrganization->>apiClient: GET /tenants/{id}
    apiClient->>API: request tenant
    API-->>apiClient: tenant data {id,name,slug}
    apiClient-->>useOrganization: data
    useOrganization-->>TenantLayout: organization
    TenantLayout->>Browser: render header with orgName
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped through sessions, found an org to share,
Fields tucked in pockets — id, slug, and name there.
I nudged the adapter to plant a tenant seed,
Hydration retried until the branches succeed,
Now layouts and guards hum tenant-aware delight 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(identity): harden permission system, resolve test failures, and improve auth coverage' is not fully related to the PR's main changes. The PR primarily adds organization context propagation and tenant-aware features with accompanying test infrastructure improvements, but the title emphasizes 'hardening the permission system' which is only one aspect of broader identity/auth enhancements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/tenant-provisioning-hook

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/modules/identity/tenants/TenantList.spec.tsx (1)

158-200: ⚠️ Potential issue | 🟡 Minor

Assert both rows/statuses in read‑only test.

This test only checks for "Active" text, so it would still pass if the suspended row/status fails to render. Consider asserting the "Suspended" status (and/or the "Beta Inc" row) to validate the read‑only path more fully.

apps/web/src/modules/identity/pages/TenantSettingsPage.tsx (1)

34-66: 🧹 Nitpick | 🔵 Trivial

Prefer keepDirtyValues to prevent form resets from overwriting in-progress edits.

form.reset() runs on background refetches and will overwrite all field values and clear dirty state by default. While guarding with !form.formState.isDirty would work, the documented react-hook-form pattern is to use the keepDirtyValues option when resetting. This allows non-dirty fields to update from the server while preserving user-edited fields:

     useEffect(() => {
         if (org) {
             form.reset({
                 name: org.name,
-            });
+            }, { keepDirtyValues: true });
         }
     }, [org, form]);

This is more robust than blocking all resets while dirty—it keeps user edits intact while still syncing untouched fields from the server. Note: keepDirtyValues requires that formState.dirtyFields or formState.isDirty is subscribed for the feature to work as expected.

🤖 Fix all issues with AI agents
In `@apps/api/src/modules/identity/auth/permissions.guard.ts`:
- Around line 41-56: Replace the detailed client-facing ForbiddenException in
permissions.guard.ts with a generic message while moving the sensitive
permission details into an internal log: when hasPerm is false (the block
referencing requiredPermissions, this.hasPermission, and user.permissions), call
this.logger.warn or this.logger.debug including requiredStrings and
user.permissions for diagnostics, then throw new ForbiddenException('Missing
required permissions') (or similarly generic text) instead of embedding the full
permission lists; keep the detailed strings only in logs and do not expose them
in the exception message.

In `@apps/api/src/modules/identity/tenants/tenants.controller.ts`:
- Around line 32-44: The findOne controller currently loads all tenants via
tenantProvider.findAllForUser(req.user.id) and filters in memory; add a provider
method tenantProvider.findOneForUser(userId: string, tenantId: string) that
queries for the single tenant (or returns null) and update the
TenantsController.findOne to call tenantProvider.findOneForUser(req.user.id, id)
instead of findAllForUser, then keep the existing NotFoundException throw when
the provider returns null; ensure the new provider method is implemented in the
same provider class and used by the `@Get`(':id') handler.

In `@apps/api/src/modules/identity/tenants/tenants.validation.ts`:
- Around line 14-17: UpdateTenantSchema currently allows any string of length
>=3 for the slug field; add the same regex constraint used by system-admin to
enforce allowed characters. Modify the slug validator on UpdateTenantSchema to
remain optional and min(3) but also include .regex(/^[a-z0-9-]+$/) so slugs only
contain lowercase letters, digits and hyphens (match the system-admin and web
schemas) — update the slug property in the UpdateTenantSchema object
accordingly.

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 59-129: refreshSession currently never increments retryCount so
MAX_RETRIES has no effect across calls; move the attempt counter out of the
function (e.g., add a module-level or React ref like provisionAttemptsRef) and
use it instead of the local retryCount: check provisionAttemptsRef.current <
MAX_RETRIES before trying auto-provisioning, increment
provisionAttemptsRef.current each time you attempt provisioning (and optionally
reset it on a successful provision/hydration), and remove/ignore the unused
local retryCount parameter in refreshSession so repeated calls honor the global
cap.

In `@packages/identity/src/adapters/better-auth.adapter.spec.ts`:
- Around line 75-82: The tenant provider mock created by mkTenantProvider only
implements listForUser but the code now calls findAllForUser and
provisionTenantForUser; update mkTenantProvider to mirror the ITenantProvider
surface by adding vi.fn() mocks for findAllForUser and provisionTenantForUser
(and any other missing members from ITenantProvider), so tests that exercise the
new provisioning hook call the mocked methods instead of throwing at runtime.

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 58-116: The hook uses the wrong context field and isn't wrapped
with the required middleware: change the handler in tenantProvisioningPlugin to
be wrapped by createAuthMiddleware and read the parsed response from
ctx.context.returned (not ctx.response); extract the user from that returned
object, keep the existing tenantProvider calls (tenantProvider.findAllForUser
and tenantProvider.provisionTenantForUser) and error handling, and ensure the
handler returns void or uses ctx.json(...) / a proper Response instead of
returning { response: ctx.response }; also add the necessary import for
createAuthMiddleware.

In `@packages/identity/src/adapters/drizzle-permission.adapter.ts`:
- Around line 26-28: Extract the duplicated role normalization and admin check
into a private helper method (e.g., isPrivilegedRole(roleName: string | null |
undefined): boolean) and use it from both can() and getPermissions(); replace
the existing logic that lowercases context.roleName and checks for "owner" or
"system admin" with calls to isPrivilegedRole(context.roleName) so both methods
share the same normalized comparison and avoid duplication.
- Around line 27-28: Extract the hardcoded privileged role strings into a shared
constant (e.g., PRIVILEGED_ROLES = ["owner", "system admin"] as const) and add a
helper method isPrivilegedRole(roleName: string | null | undefined): boolean
that normalizes (toLowerCase/empty string) and checks inclusion against
PRIVILEGED_ROLES; then replace the direct checks that use
context.roleName/roleName (the occurrence around roleName = context.roleName...
and the similar check at lines ~69-70) with calls to isPrivilegedRole to
centralize the logic and avoid duplicated magic strings.

Comment thread apps/api/src/modules/identity/auth/permissions.guard.ts
Comment thread apps/api/src/modules/identity/tenants/tenants.controller.ts
Comment thread apps/api/src/modules/identity/tenants/tenants.validation.ts
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx Outdated
Comment thread packages/identity/src/adapters/better-auth.adapter.spec.ts
Comment thread packages/identity/src/adapters/better-auth.adapter.ts
Comment thread packages/identity/src/adapters/drizzle-permission.adapter.ts Outdated
Comment on lines +27 to +28
const roleName = context.roleName ? context.roleName.toLowerCase() : "";
if (roleName === "owner" || roleName === "system admin") return true;

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.

🧹 Nitpick | 🔵 Trivial

Consider extracting privileged role names as constants.

The magic strings "owner" and "system admin" are hardcoded in multiple places. Extracting them to constants improves maintainability and reduces the risk of typos.

♻️ Proposed refactor
const PRIVILEGED_ROLES = ["owner", "system admin"] as const;

// Then in the helper method:
private isPrivilegedRole(roleName: string | null | undefined): boolean {
  const normalized = roleName?.toLowerCase() ?? "";
  return PRIVILEGED_ROLES.includes(normalized as typeof PRIVILEGED_ROLES[number]);
}

Also applies to: 69-70

🤖 Prompt for AI Agents
In `@packages/identity/src/adapters/drizzle-permission.adapter.ts` around lines 27
- 28, Extract the hardcoded privileged role strings into a shared constant
(e.g., PRIVILEGED_ROLES = ["owner", "system admin"] as const) and add a helper
method isPrivilegedRole(roleName: string | null | undefined): boolean that
normalizes (toLowerCase/empty string) and checks inclusion against
PRIVILEGED_ROLES; then replace the direct checks that use
context.roleName/roleName (the occurrence around roleName = context.roleName...
and the similar check at lines ~69-70) with calls to isPrivilegedRole to
centralize the logic and avoid duplicated magic strings.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/api/src/modules/identity/tenants/tenants.controller.ts`:
- Around line 50-55: The updateDetails endpoint currently calls
tenantProvider.update(id, body) without verifying the requesting user is a
member of the target tenant; add the same membership validation used by the
findOne endpoint by invoking findOneForUser (or the equivalent method on
tenantProvider) to ensure the current user belongs to the tenant with the
provided id before calling tenantProvider.update; if the membership check fails,
throw the same not-found/forbidden error used by findOne, otherwise proceed to
call update(id, body).

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 100-101: The retry counter provisionAttemptsRef.current is never
reset, so it should be cleared back to 0 when provisioning succeeds or when the
user logs out; update the code paths where tenantsFound becomes true or where
the provisioning success handler runs to set provisionAttemptsRef.current = 0,
and also reset it inside the logout function (or equivalent sign-out handler) so
subsequent logins start fresh while keeping the MAX_RETRIES logic unchanged.

In `@docs/architecture/flexible_identity_strategy.md`:
- Around line 72-104: The PLANS config uses JavaScript Infinity for enterprise
limits (PLANS.enterprise.limits.maxMembers and
PLANS.enterprise.limits.maxProjects) which will become null when
JSON-serialized; replace Infinity with a JSON-safe sentinel (e.g.,
Number.MAX_SAFE_INTEGER or a documented sentinel like -1) and update any
consumers to interpret that sentinel as "unlimited" (adjust parsing/validation
logic where PLANS is loaded or read to treat the chosen sentinel as unlimited).

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 77-86: The extraction branch duplicates logic: both the `"user"`
and `"token"` checks assign returned.user to the local user variable (returned
and user). Fix by consolidating into a single condition (e.g., if ("user" in
returned || "token" in returned) user = returned.user as UserInterface) or, if
sign-in responses sometimes only carry a token and no user, implement the
explicit token-handling path (decode/validate returned.token to obtain user)
instead; update the logic around the returned variable and local user assignment
in better-auth.adapter's extraction block accordingly.

Comment thread apps/api/src/modules/identity/tenants/tenants.controller.ts
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx Outdated
Comment thread docs/architecture/flexible_identity_strategy.md
Comment on lines +77 to +86
if (returned && typeof returned === "object") {
if ("user" in returned) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
user = returned.user as UserInterface;
} else if ("token" in returned) {
// Login response
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
user = returned.user as UserInterface;
}
}

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.

🧹 Nitpick | 🔵 Trivial

User extraction logic has a potential bug for "token" case.

The condition at line 81 checks for "token" in returned but then accesses returned.user at line 84, which is identical to the "user" case. For sign-in responses, if the structure is { token: "...", user: {...} }, this works. However, the comment says "Login response" but the code path is the same as the sign-up case.

If this is intentional (both have .user), consider consolidating:

♻️ Suggested simplification
               if (returned && typeof returned === "object") {
-                if ("user" in returned) {
-                  // eslint-disable-next-line `@typescript-eslint/no-unsafe-member-access`
-                  user = returned.user as UserInterface;
-                } else if ("token" in returned) {
-                  // Login response
-                  // eslint-disable-next-line `@typescript-eslint/no-unsafe-member-access`
+                if ("user" in returned) {
+                  // Both sign-up and sign-in responses contain user object
                   user = returned.user as UserInterface;
                 }
               }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (returned && typeof returned === "object") {
if ("user" in returned) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
user = returned.user as UserInterface;
} else if ("token" in returned) {
// Login response
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
user = returned.user as UserInterface;
}
}
if (returned && typeof returned === "object") {
if ("user" in returned) {
// Both sign-up and sign-in responses contain user object
user = returned.user as UserInterface;
}
}
🤖 Prompt for AI Agents
In `@packages/identity/src/adapters/better-auth.adapter.ts` around lines 77 - 86,
The extraction branch duplicates logic: both the `"user"` and `"token"` checks
assign returned.user to the local user variable (returned and user). Fix by
consolidating into a single condition (e.g., if ("user" in returned || "token"
in returned) user = returned.user as UserInterface) or, if sign-in responses
sometimes only carry a token and no user, implement the explicit token-handling
path (decode/validate returned.token to obtain user) instead; update the logic
around the returned variable and local user assignment in better-auth.adapter's
extraction block accordingly.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@apps/api/src/modules/identity/tenants/tenants.controller.ts`:
- Around line 26-30: The commented-out `@RequirePermission` decorator on the
findAll method is confusing; either remove the commented line entirely or
replace it with a clear code comment above findAll explaining the decision to
allow fetching own tenants without context; update the comment to reference the
permission decorator name (`@RequirePermission`) and the
tenantProvider.findAllForUser call so future maintainers understand why
permission enforcement was omitted.

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 79-105: The code currently treats a failed /tenants GET the same
as an empty tenant list and will auto-provision, causing duplicate tenants; fix
by adding a fetchSucceeded flag (e.g., let fetchSucceeded = false) and set
fetchSucceeded = true inside the try after apiClient.get('/tenants') returns
(before checking tenants.length), keep tenantsFound true only when
tenants.length > 0, and change the provisioning guard to require fetchSucceeded
(if (!tenantsFound && fetchSucceeded && provisionAttemptsRef.current <
MAX_RETRIES) { ... }). This ensures apiClient.post('/auth/provision-tenant')
only runs when the tenant fetch actually succeeded and returned no tenants.
- Around line 141-145: The catch in refreshSession currently clears auth state
for any error; change it to only clear token/user when the error indicates an
explicit unauthorized response (e.g., status === 401 or an auth-specific error
type). For other errors (network/timeouts), do not call setToken or setUser—just
log the error and call setIsLoading(false) to stop the spinner and preserve the
current session. Update the catch in the refreshSession function (where
setToken, setUser, setIsLoading are used) so unauthorized -> clear state,
otherwise -> keep state and setIsLoading(false).

In `@docs/architecture/flexible_identity_strategy.md`:
- Around line 152-169: The guard's canActivate in plan-limit.guard.ts currently
uses request.path.includes('/invite') which risks false positives; replace
substring matching with route metadata by creating a decorator (e.g.,
`@CheckPlanLimit`('invite')) and have the PlanLimitGuard read that metadata via
Nest's Reflector inside canActivate; when metadata is present, call
memberRepo.count(tenant.id) and CapabilityService.canInviteMembers (or the
appropriate capability method) and throw ForbiddenException if check fails;
update controllers to use `@CheckPlanLimit`('invite') on the invite route instead
of relying on path text.
- Around line 175-200: The component uses PLANS[tenant.planId] directly which
can be undefined and cause runtime errors; update the TeamSettings setup (where
useTenant is used and plan is assigned) to default to the free plan when the
lookup fails (e.g., set plan to PLANS[tenant.planId] || PLANS.free) so all
downstream references like plan.features and plan.limits.maxMembers are safe;
ensure any other places in TeamSettings that read plan are covered by this
defensive fallback.

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 740-748: The orphaned comment after calling
this.auth.api.sendVerificationEmail should be resolved: either remove the
comment and leave the call as-is (relying on Better Auth to throw on error), or
capture and validate the response from this.auth.api.sendVerificationEmail by
assigning its return to a variable (e.g., const res = await
this.auth.api.sendVerificationEmail(...)) and checking expected success fields
or status before proceeding; update error handling to throw/log if the response
indicates failure. Ensure changes target the sendVerificationEmail call in
better-auth.adapter.ts.

Comment thread apps/api/src/modules/identity/tenants/tenants.controller.ts
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx
Comment on lines +152 to +169
```typescript
// apps/api/src/guards/plan-limit.guard.ts

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const tenant = request.tenant;

if (request.path.includes('/invite')) {
const currentCount = await this.memberRepo.count(tenant.id);
const capability = new CapabilityService(tenant);

if (!await capability.canInviteMembers(currentCount)) {
throw new ForbiddenException("Upgrade your plan to invite more members.");
}
}
return true;
}
```

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.

🧹 Nitpick | 🔵 Trivial

Consider route-based decoration over path string matching.

The guard example uses request.path.includes('/invite') which could inadvertently match unintended routes (e.g., /users/invite-history). In production, prefer a decorator-based approach (e.g., @CheckPlanLimit('invite')) or explicit route metadata rather than substring matching.

🤖 Prompt for AI Agents
In `@docs/architecture/flexible_identity_strategy.md` around lines 152 - 169, The
guard's canActivate in plan-limit.guard.ts currently uses
request.path.includes('/invite') which risks false positives; replace substring
matching with route metadata by creating a decorator (e.g.,
`@CheckPlanLimit`('invite')) and have the PlanLimitGuard read that metadata via
Nest's Reflector inside canActivate; when metadata is present, call
memberRepo.count(tenant.id) and CapabilityService.canInviteMembers (or the
appropriate capability method) and throw ForbiddenException if check fails;
update controllers to use `@CheckPlanLimit`('invite') on the invite route instead
of relying on path text.

Comment on lines +175 to +200
```tsx
// apps/web/src/pages/TeamSettings.tsx

const { tenant } = useTenant();
const plan = PLANS[tenant.planId];

return (
<div>
<h1>Team Settings</h1>

{/* Feature Flag: SSO */}
{plan.features.includes('sso') ? (
<SSOSettings />
) : (
<UpgradeBanner feature="SSO" />
)}

{/* Limit Check: Invite Button */}
{memberCount >= plan.limits.maxMembers ? (
<DisableInviteButton reason="Limit Reached" />
) : (
<InviteButton />
)}
</div>
);
```

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.

🧹 Nitpick | 🔵 Trivial

Add fallback handling for unknown planId on frontend.

The frontend example directly accesses PLANS[tenant.planId] without a fallback. If planId is invalid or undefined, this could cause runtime errors. Consider mirroring the backend's defensive pattern:

const plan = PLANS[tenant.planId] || PLANS.free;

This ensures graceful degradation to the free tier if the plan lookup fails.

🤖 Prompt for AI Agents
In `@docs/architecture/flexible_identity_strategy.md` around lines 175 - 200, The
component uses PLANS[tenant.planId] directly which can be undefined and cause
runtime errors; update the TeamSettings setup (where useTenant is used and plan
is assigned) to default to the free plan when the lookup fails (e.g., set plan
to PLANS[tenant.planId] || PLANS.free) so all downstream references like
plan.features and plan.limits.maxMembers are safe; ensure any other places in
TeamSettings that read plan are covered by this defensive fallback.

Comment thread packages/identity/src/adapters/better-auth.adapter.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/identity/src/adapters/better-auth.adapter.ts (1)

3-12: 🧹 Nitpick | 🔵 Trivial

API contracts verified; minor refactor suggested for redundant user extraction.

The BetterAuthApi interface accurately reflects v1.4.10 signatures, and asResponse is correctly used throughout:

  • signUpEmail(asResponse: false) → typed object ✓
  • signInEmail(asResponse: true) → Response ✓
  • sendVerificationEmail(asResponse: true) → Response with proper .ok / .json() handling ✓
  • Hook matcher paths (/auth/sign-up/email, /auth/sign-in/email, /auth/callback/*) correctly target better-auth routes.

One minor improvement: lines 112–123 have redundant logic—both branches of the "user" in returned / "token" in returned check assign returned.user. Consider simplifying to:

if (returned?.user) {
  user = returned.user as UserInterface;
}
🤖 Fix all issues with AI agents
In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 88-89: The code currently calls tenants.sort(...) which mutates
the original response array; change this to a non-mutating sort (e.g., use a
shallow copy like [...tenants].sort(...) or tenants.toSorted(...)) when
computing sorted/activeTenant in AuthProvider (look for the tenants, sorted and
activeTenant variables) so the API response array is not mutated in place.

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 101-105: The matcher in the adapter currently checks context.path
for prefixes like "/auth/sign-up/email" and "/auth/callback/..." but better-auth
v1.4.10 exposes routes without the "/auth" base, so update the matcher logic
inside the matcher function (the matcher tied to createAuthMiddleware) to check
the relative paths used by better-auth: e.g., compare context.path to
"/sign-up/email" and "/sign-in/email" and use startsWith("/callback/") (or
otherwise strip a leading "/auth" if present) so tenant auto-provisioning fires
for sign-up, sign-in, and social callback routes; ensure you reference the
matcher definition and context.path in your change.
- Around line 767-779: The code currently casts
this.api.sendVerificationEmail(...) to Response but the method returns
Promise<{status: boolean; error?: {message: string}} | Response>, so add a type
guard before treating the result as Response: after const res = await
this.api.sendVerificationEmail(...), detect if the returned value is the object
form (e.g., typeof res === 'object' && 'status' in res && typeof (res as
any).status === 'boolean') and handle that case by checking res.status and using
res.error?.message for the thrown Error; otherwise treat it as a Response and
use res.ok/res.json() as before. Ensure references to sendVerificationEmail and
the local variable res are used in the guard and error paths.

Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx Outdated
Comment thread packages/identity/src/adapters/better-auth.adapter.ts Outdated
Comment thread packages/identity/src/adapters/better-auth.adapter.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 159-167: The existing comment incorrectly claims setTimeout is
used to avoid a "setState in effect" lint error; update the comment above the
useEffect that schedules refreshSession(false) to accurately state why
setTimeout(..., 0) is used (to defer execution to the next macrotask to avoid
hydration/timing issues or Strict Mode double-invoke side-effects), not to avoid
calling setState inside useEffect; locate the useEffect block that references
refreshSession and the timer variable and replace the misleading sentence with a
concise note about deferring to the next tick for timing/Strict Mode
considerations.
- Around line 115-123: The tenant refetch block that calls
apiClient.get('/tenants') should pick the same tenant ordering as earlier and
must not swallow errors silently: sort res.data by createdAt (newest-first, same
comparator used where tenants are initially chosen) before selecting the tenant
to pass into hydrateUser, and replace the empty catch (catch (e) { void e; })
with a logged error (e.g., console.error or the app logger) that includes the
caught error and context (e.g., "failed to refetch tenants after provisioning");
keep the existing setIsLoading(false) and provisionAttemptsRef.current reset on
success as-is.
- Around line 60-157: Add unit tests for the refreshSession function to exercise
all branches: mock authClient.getSession, apiClient.get, and apiClient.post to
validate the fast path when apiUser.organizationId is present (ensure
hydrateUser called and loading cleared), tenant fetch success (tenants > 0 leads
to hydrateUser with active tenant and resets provisionAttemptsRef), tenant fetch
failure (fetchSucceeded false should NOT trigger auto-provision), fetchSucceeded
&& !tenantsFound triggers auto-provision and retry behavior up to MAX_RETRIES
(assert apiClient.post called correct times and provisionAttemptsRef
increments/resets), and error handling for 401 vs non-401 errors (ensure
token/user cleared only on 401). Use spies/mocks on hydrateUser, setIsLoading
effects, and inspect calls to authClient.getSession, apiClient.get,
apiClient.post, and the provisionAttemptsRef/ MAX_RETRIES to verify expected
behavior.

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Line 109: The handler's context parameter is currently typed as ctx: any in
the createAuthMiddleware call; replace that any with the proper middleware
context type exported by better-auth (e.g., import and use the
middleware/context type from 'better-auth' or 'better-auth/api') by updating the
handler signature (handler: createAuthMiddleware(async (ctx: BetterAuthContext)
=> { ... })) and adding the necessary import, or if no types are exported, leave
a brief inline comment next to ctx: any explaining that types are unavailable
upstream and link to an issue/PR tracking typed support so reviewers know the
rationale.

In `@packages/identity/src/identity.module.spec.ts`:
- Around line 102-125: The test executes asyncProviders' factories blindly and
swallows errors, causing fragility when a factory expects injected args; update
the loop that iterates asyncProviders to inspect p.inject and call p.useFactory
with appropriate mocked dependencies for each case (use TENANT_PROVIDER and
AUTH_PROVIDER branches for auth/user factories and for other factories build a
mock args array matching p.inject contents), and avoid calling await
p.useFactory() with no args; also stop silently swallowing unexpected
errors—either assert expected throws or log the error so failing factories in
registerAsync are visible (reference asyncProviders, p.useFactory,
TENANT_PROVIDER, AUTH_PROVIDER).
- Around line 38-53: The coverage-driven loop that invokes provider factories is
fragile and swallows real errors; remove the block that iterates over providers
and calls p.useFactory(...) inside a try-catch, or replace it with explicit unit
tests that call each provider factory (referencing providers and the useFactory
field) with well-defined, documented inputs and assertions instead of guessing
argument shapes based on TENANT_PROVIDER and AUTH_PROVIDER; if you keep any
automated invocation, ensure it fails loudly by removing the empty catch and/or
by using a mapping of provider tokens to explicit, correct mock arguments so
factories are exercised deterministically.
- Around line 139-143: Replace the fragile index-based access of mod.providers
with a lookup by the provider's token: search the (mod.providers as
FactoryProvider[]) array for the FactoryProvider whose provide field matches the
token used to register the identity provider, then call its useFactory and
assert it rejects; specifically change the code that reads (mod.providers as
FactoryProvider[])[0] to find the provider by .provide and then invoke
provider.useFactory() for the assertion.

Comment on lines +60 to +157
const refreshSession = useCallback(async (shouldSetLoading = true) => {
// Keep loading true during retries
if (shouldSetLoading) setIsLoading(true);

try {
// 1. Check Server Session (Cookies) - Source of Truth
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { data, error: _error } = await authClient.getSession();

if (data) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiUser = data.user as any;

// 2. Check for Organization Context in Session (Fast Path)
if (apiUser.organizationId) {
hydrateUser(data);
setIsLoading(false);
} else {
// 3. Explicitly Fetch Tenants
let tenantsFound = false;
let fetchSucceeded = false;
try {
const sessionRes = await fetch(`${API_URL}/auth/refresh-session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${data.session.token}` // Ensure Token is explicit if cookie fails
},
credentials: 'include',
});
if (sessionRes.ok) {
enrichedData = await sessionRes.json();
} else {
console.warn("AuthProvider: Enriched fetch failed", sessionRes.status);
const res = await apiClient.get<Tenant[]>('/tenants');
fetchSucceeded = true;
const tenants = res.data;

if (tenants.length > 0) {
// Found tenants! Pick the first one (or recently active if we tracked it)
const sorted = [...tenants].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const activeTenant = sorted[0];

hydrateUser(data, activeTenant);
setIsLoading(false);
tenantsFound = true;
provisionAttemptsRef.current = 0; // Reset on success
return;
}
} catch (err) {
console.error("Failed to fetch enriched session", err);
} catch (error_) {
// Don't fail completely, try provisioning if really needed
// Log error but continue to allow fallback if logic permits
console.warn("[AuthProvider] Failed to fetch tenants:", error_);
}

// Fallback to basic data if enriched fails, but DO NOT auto-provision based on incomplete data.
if (enrichedData) {
const sessionUser = enrichedData.user as unknown as AuthUser;

// AUTO-PROVISION CHECK
// FIX: Do not auto-provision if the user is a Platform Admin (they have wildcard permission)
const isPlatformUser = (sessionUser.permissions || []).includes('*');
// 4. Fallback: Auto-Provisioning (Only if NO tenants found AND fetch succeeded)
// If fetch failed, we shouldn't auto-provision because we don't know if tenants exist.
if (!tenantsFound && fetchSucceeded && provisionAttemptsRef.current < MAX_RETRIES) {
provisionAttemptsRef.current += 1;
try {
await apiClient.post('/auth/provision-tenant');

// Immediately re-check session after provisioning success
// This avoids recursive call issues in useCallback
const { data: newData } = await authClient.getSession();
if (newData) {
// Try fetching tenants one last time
try {
const res = await apiClient.get<Tenant[]>('/tenants');
if (res.data.length > 0) {
hydrateUser(newData, res.data[0]);
setIsLoading(false);
provisionAttemptsRef.current = 0; // Reset on success
return;
}
} catch (e) { void e; }

if (!sessionUser.hasTenant && !sessionUser.organizationId && !isPlatformUser) {
if (!API_URL) {
console.error("VITE_API_URL is missing!");
// Or at least hydrate what we have
hydrateUser(newData);
setIsLoading(false);
provisionAttemptsRef.current = 0; // Reset on success
return;
}
const res = await fetch(`${API_URL}/auth/provision-tenant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${data.session.token}`
},
credentials: 'include',
});

if (res.ok) {
// Retry Enriched Fetch
const retryRes = await fetch(`${API_URL}/auth/refresh-session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${data.session.token}`
},
credentials: 'include',
});

if (retryRes.ok) {
try {
const refreshed = await retryRes.json();
hydrateUser(refreshed);
} catch (err) {
console.warn("Retry refresh-session JSON parse failed:", err);
hydrateUser(enrichedData);
}
} else {
console.warn("Retrying Enriched Fetch failed:", retryRes.status);
// Fallback to initial enrichedData (which lacks tenant but is better than nothing)
// or just data if enriched was null (though we are inside enrichedData check here)
hydrateUser(enrichedData);
}
} else {
console.error("Provisioning Failed!", res.status);
hydrateUser(enrichedData);
}
} else {
// Already has tenant
hydrateUser(enrichedData);
} catch (provError) {
console.error("[AuthProvider] Auto-Provisioning Failed:", provError);
}
} else {
// Enriched fetch failed (e.g. network error, 401).
// Fallback to basic session data but SKIP auto-provisioning to avoid creating duplicate tenants.
// The user might see a degraded UI (no org context), but that's better than corrupting state.
console.warn("AuthProvider: Skipping auto-provisioning due to missing enriched data.");
hydrateUser(data);
}
} else {
// Server says "No Session". Trust it.
console.warn("AuthProvider: No Session Found (data is null). Error:", error);
setToken(undefined);
setUser(null);
}
} catch (error) {
console.error('Failed to fetch session', error);
} finally {
// Enterprise pattern: Ensure minimum loading duration to prevent flash
// This is used by Vercel, Linear, Stripe, etc.
const elapsed = Date.now() - startTime;
const minLoadingDuration = 300; // 300ms minimum

if (elapsed < minLoadingDuration) {
// Wait for the remaining time
setTimeout(() => {
setIsLoading(false);
}, minLoadingDuration - elapsed);
} else {

// If exhausted retries or provisioning failed:
hydrateUser(data);
setIsLoading(false);
}
} else {
setToken(undefined);
setUser(null);
setIsLoading(false);
}
};

const hydrateUser = (data: { user: unknown; session: { token: string } }) => {
const apiUser = data.user as Record<string, unknown>;
console.log("AuthProvider Hydrate:", JSON.stringify(apiUser, null, 2));

// Better Auth returns 'roles' as an array OR 'role' as string. Normalize to array.
let finalRoles: string[] = ['user'];
if (Array.isArray(apiUser.roles)) {
finalRoles = apiUser.roles as string[];
} else if (typeof apiUser.role === 'string') {
finalRoles = [apiUser.role];
} catch (err) {
console.error("[AuthProvider] refreshSession Failure", err);

// Only clear auth state on explicit Unauthorized errors
const errorStatus = (err as { status?: number; statusCode?: number })?.status || (err as { status?: number; statusCode?: number })?.statusCode;
if (errorStatus === 401) {
setToken(undefined);
setUser(null);
}

const authUser: AuthUser = {
id: String(apiUser.id),
email: String(apiUser.email),
name: typeof apiUser.name === 'string' ? apiUser.name : undefined,
roles: finalRoles,
organizationName: typeof apiUser.organizationName === 'string' ? apiUser.organizationName : undefined,
hasTenant: !!apiUser.hasTenant,
permissions: Array.isArray(apiUser.permissions) ? (apiUser.permissions as string[]) : []
};
setToken(data.session.token);
setUser(authUser);
// For other errors (network, timeout, 500) -> Keep existing state (optimistic)
setIsLoading(false);
}
}, [hydrateUser]);

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find test files and coverage setup for AuthProvider

echo "=== Test files for AuthProvider ==="
find . -type f \( -name "*.test.ts" -o -name "*.test.tsx" -o -name "*.spec.ts" -o -name "*.spec.tsx" \) | grep -i auth | head -20

echo -e "\n=== Checking for AuthProvider test file ==="
find . -type f \( -name "*AuthProvider*" \) | grep -E "\.(test|spec)\." || echo "No dedicated AuthProvider test file found"

echo -e "\n=== Coverage configuration files ==="
find . -maxdepth 3 -type f \( -name "jest.config.*" -o -name "vitest.config.*" -o -name ".nycrc*" \) | head -10

echo -e "\n=== Checking package.json for test scripts ==="
grep -A 5 '"test:cov"' package.json 2>/dev/null || echo "No test:cov script found in root package.json"

echo -e "\n=== Check for jest/vitest config in apps/web ==="
ls -la apps/web/ | grep -E "jest|vitest|.*\.config"

Repository: pramodnarayana/nexiom

Length of output: 1360


🏁 Script executed:

#!/bin/bash
# Check the auth-provider test file
echo "=== AuthProvider Test File ==="
wc -l ./apps/web/src/test/auth-provider.spec.ts
cat -n ./apps/web/src/test/auth-provider.spec.ts

echo -e "\n=== apps/web vitest.config.ts ==="
cat -n ./apps/web/vitest.config.ts

Repository: pramodnarayana/nexiom

Length of output: 7478


🏁 Script executed:

#!/bin/bash
# Check what authProvider is and where it's used
echo "=== Location of authProvider export ==="
rg -A 5 "export.*authProvider|authProvider.*export" apps/web/src --type ts --type tsx | head -30

echo -e "\n=== Check AuthProvider.tsx component ==="
head -80 apps/web/src/shared/lib/auth/AuthProvider.tsx

echo -e "\n=== Check for refreshSession tests ==="
rg -B 2 -A 5 "refreshSession" apps/web/src/test/ --type ts --type tsx 2>/dev/null || echo "No refreshSession tests found"

echo -e "\n=== Check for coverage thresholds in vitest config ==="
rg "threshold|lines|statements|branches|functions" apps/web/vitest.config.ts

Repository: pramodnarayana/nexiom

Length of output: 3622


🏁 Script executed:

#!/bin/bash
# Check full vitest config
echo "=== Full vitest.config.ts ==="
cat apps/web/vitest.config.ts

echo -e "\n=== Search for AuthProvider component usage in tests ==="
find apps/web/src -type f \( -name "*.spec.ts" -o -name "*.spec.tsx" -o -name "*.test.ts" -o -name "*.test.tsx" \) -exec grep -l "AuthProvider" {} \;

echo -e "\n=== Check if AuthProvider.tsx is included in coverage ==="
head -40 apps/web/src/shared/lib/auth/AuthProvider.tsx | grep -E "^(import|export)"

echo -e "\n=== Look for integration tests that might test AuthProvider ==="
find apps/web/src -type f -name "*.spec.tsx" | xargs grep -l "Context\|Provider" 2>/dev/null | head -5

Repository: pramodnarayana/nexiom

Length of output: 1969


🏁 Script executed:

#!/bin/bash
# Check TenantList.spec.tsx to see AuthProvider testing
echo "=== TenantList.spec.tsx (AuthProvider usage) ==="
wc -l apps/web/src/modules/identity/tenants/TenantList.spec.tsx
head -100 apps/web/src/modules/identity/tenants/TenantList.spec.tsx

echo -e "\n=== Check if there's a .nycrc or coverage threshold config ==="
find . -maxdepth 2 -name ".nycrc*" -o -name "coverage.json" | xargs cat 2>/dev/null || echo "No coverage threshold file found"

echo -e "\n=== Check turbo.json for test:cov configuration ==="
rg -A 10 '"test:cov"' turbo.json 2>/dev/null || echo "No test:cov in turbo.json"

echo -e "\n=== Search for any coverage threshold comments/config in package.json ==="
grep -B 2 -A 2 "coverage\|threshold" package.json 2>/dev/null || echo "No coverage threshold in root package.json"

Repository: pramodnarayana/nexiom

Length of output: 4131


🏁 Script executed:

#!/bin/bash
# Check for CI configuration that might show test failures
echo "=== Check for GitHub Actions or CI config ==="
find .github -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -5 | xargs grep -l "test:cov" 2>/dev/null || echo "No CI with test:cov found"

echo -e "\n=== Check if there's explicit coverage threshold anywhere ==="
rg "threshold|lines.*[0-9]|statements.*[0-9]|branches.*[0-9]" --type ts --type tsx --type json -A 1 -B 1 2>/dev/null | head -30

echo -e "\n=== Check the actual AuthProvider.tsx to count code paths ==="
sed -n '60,157p' apps/web/src/shared/lib/auth/AuthProvider.tsx | grep -E "if|catch|try|return" | wc -l

echo -e "\n=== Verify refreshSession function in AuthProvider.tsx ==="
grep -n "const refreshSession" apps/web/src/shared/lib/auth/AuthProvider.tsx

Repository: pramodnarayana/nexiom

Length of output: 403


🏁 Script executed:

#!/bin/bash
# Check the CI configuration
echo "=== GitHub Actions CI config ==="
cat .github/workflows/ci.yml

echo -e "\n=== Search for any coverage report or threshold in CI ==="
grep -i "coverage\|threshold\|report" .github/workflows/ci.yml

Repository: pramodnarayana/nexiom

Length of output: 968


🏁 Script executed:

#!/bin/bash
# Final comprehensive search for refreshSession testing
echo "=== Search all test files for refreshSession references ==="
find apps/web/src -type f \( -name "*.spec.ts" -o -name "*.spec.tsx" -o -name "*.test.ts" -o -name "*.test.tsx" \) -exec grep -l "refreshSession\|getSession\|tenants\|provision" {} \;

echo -e "\n=== Count test files in apps/web ==="
find apps/web/src -type f \( -name "*.spec.ts" -o -name "*.spec.tsx" -o -name "*.test.ts" -o -name "*.test.tsx" \) | wc -l

echo -e "\n=== Check if AuthProvider component itself is exported and tested ==="
grep -n "export.*AuthProvider\|export.*refreshSession" apps/web/src/shared/lib/auth/AuthProvider.tsx

echo -e "\n=== Final check: Lines 60-157 of AuthProvider.tsx ==="
sed -n '60,157p' apps/web/src/shared/lib/auth/AuthProvider.tsx

Repository: pramodnarayana/nexiom

Length of output: 5514


🏁 Script executed:

#!/bin/bash
# Check if any of the found test files actually test refreshSession internals
echo "=== Check AdminLayout.spec.tsx for refreshSession testing ==="
grep -B 3 -A 10 "refreshSession\|getSession" apps/web/src/app/layouts/AdminLayout.spec.tsx 2>/dev/null | head -30

echo -e "\n=== Check TenantLayout.spec.tsx ==="
grep -B 3 -A 10 "refreshSession\|getSession" apps/web/src/app/layouts/TenantLayout.spec.tsx 2>/dev/null | head -30

Repository: pramodnarayana/nexiom

Length of output: 702


Add unit tests for the refreshSession function to cover all code paths.

The refreshSession function has multiple complex code paths (fast path, tenant fetch, auto-provisioning with retries, error handling) that are currently untested. Tests only mock this function; they don't verify the actual implementation logic for:

  • Fast path when organizationId exists
  • Successful and failed tenant fetches
  • Auto-provisioning flow and retry logic (up to MAX_RETRIES)
  • 401 vs other error handling
  • Edge cases (e.g., fetchSucceeded && !tenantsFound)
🤖 Prompt for AI Agents
In `@apps/web/src/shared/lib/auth/AuthProvider.tsx` around lines 60 - 157, Add
unit tests for the refreshSession function to exercise all branches: mock
authClient.getSession, apiClient.get, and apiClient.post to validate the fast
path when apiUser.organizationId is present (ensure hydrateUser called and
loading cleared), tenant fetch success (tenants > 0 leads to hydrateUser with
active tenant and resets provisionAttemptsRef), tenant fetch failure
(fetchSucceeded false should NOT trigger auto-provision), fetchSucceeded &&
!tenantsFound triggers auto-provision and retry behavior up to MAX_RETRIES
(assert apiClient.post called correct times and provisionAttemptsRef
increments/resets), and error handling for 401 vs non-401 errors (ensure
token/user cleared only on 401). Use spies/mocks on hydrateUser, setIsLoading
effects, and inspect calls to authClient.getSession, apiClient.get,
apiClient.post, and the provisionAttemptsRef/ MAX_RETRIES to verify expected
behavior.

Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx Outdated
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx
path.includes("/callback/")
);
},
handler: createAuthMiddleware(async (ctx: any) => {

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.

🧹 Nitpick | 🔵 Trivial

Consider typing the middleware context parameter.

The ctx: any type bypasses TypeScript checking. If better-auth exports middleware context types (e.g., from better-auth/api), consider using them for improved type safety. If types are unavailable, add a brief comment explaining why any is used.

🤖 Prompt for AI Agents
In `@packages/identity/src/adapters/better-auth.adapter.ts` at line 109, The
handler's context parameter is currently typed as ctx: any in the
createAuthMiddleware call; replace that any with the proper middleware context
type exported by better-auth (e.g., import and use the middleware/context type
from 'better-auth' or 'better-auth/api') by updating the handler signature
(handler: createAuthMiddleware(async (ctx: BetterAuthContext) => { ... })) and
adding the necessary import, or if no types are exported, leave a brief inline
comment next to ctx: any explaining that types are unavailable upstream and link
to an issue/PR tracking typed support so reviewers know the rationale.

Comment thread packages/identity/src/identity.module.spec.ts Outdated
Comment on lines +102 to +125
// Execute factories to increase coverage
const asyncProviders = (mod.providers || []) as FactoryProvider[];

for (const p of asyncProviders) {
if (typeof p.useFactory === "function") {
try {
if (p.inject?.includes(TENANT_PROVIDER)) {
// Auth provider factory
p.useFactory(
{ db: {}, email: {}, betterAuthConfig: opts.betterAuthConfig },
{},
);
} else if (p.inject?.includes(AUTH_PROVIDER)) {
// User provider
p.useFactory({ db: {} }, {});
} else {
// Tenant/Perm or Options
await p.useFactory();
}
} catch {
// ignore, just trying to hit lines
}
}
}

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.

🧹 Nitpick | 🔵 Trivial

Same fragility pattern in registerAsync test.

This repeats the same anti-pattern of blindly executing factories with suppressed errors. The logic at line 119 calls await p.useFactory() with no arguments, but some factories (like the options factory) may require injected dependencies.

🤖 Prompt for AI Agents
In `@packages/identity/src/identity.module.spec.ts` around lines 102 - 125, The
test executes asyncProviders' factories blindly and swallows errors, causing
fragility when a factory expects injected args; update the loop that iterates
asyncProviders to inspect p.inject and call p.useFactory with appropriate mocked
dependencies for each case (use TENANT_PROVIDER and AUTH_PROVIDER branches for
auth/user factories and for other factories build a mock args array matching
p.inject contents), and avoid calling await p.useFactory() with no args; also
stop silently swallowing unexpected errors—either assert expected throws or log
the error so failing factories in registerAsync are visible (reference
asyncProviders, p.useFactory, TENANT_PROVIDER, AUTH_PROVIDER).

Comment thread packages/identity/src/identity.module.spec.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/modules/identity/pages/ForgotPasswordPage.spec.tsx (1)

56-70: ⚠️ Potential issue | 🟡 Minor

Ensure console.error spy is always restored.

If the test fails before Line 70, the spy leaks into subsequent tests. Wrap the test body in try/finally or restore in afterEach to guarantee cleanup.

🔧 Suggested fix (try/finally)
-    it('handles submission error', async () => {
-        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
-        (authClient.requestPasswordReset as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
-
-        renderComponent();
-
-        const emailInput = screen.getByPlaceholderText(/email/i);
-        const submitBtn = screen.getByRole('button', { name: /send reset link/i });
-
-        fireEvent.change(emailInput, { target: { value: 'fail@example.com' } });
-        fireEvent.click(submitBtn);
-
-        await waitFor(() => {
-            expect(screen.getByText('Network error')).toBeInTheDocument();
-        });
-        consoleSpy.mockRestore();
-    });
+    it('handles submission error', async () => {
+        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+        try {
+            (authClient.requestPasswordReset as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
+
+            renderComponent();
+
+            const emailInput = screen.getByPlaceholderText(/email/i);
+            const submitBtn = screen.getByRole('button', { name: /send reset link/i });
+
+            fireEvent.change(emailInput, { target: { value: 'fail@example.com' } });
+            fireEvent.click(submitBtn);
+
+            await waitFor(() => {
+                expect(screen.getByText('Network error')).toBeInTheDocument();
+            });
+        } finally {
+            consoleSpy.mockRestore();
+        }
+    });
🤖 Fix all issues with AI agents
In `@apps/web/src/modules/identity/pages/AcceptInvitePage.spec.tsx`:
- Around line 108-110: The test "proceeds to dashboard even if silent accept
fails (idempotency)" creates consoleErrorSpy and consoleWarnSpy but restores
them unconditionally later; wrap the test body assertions and any early returns
in a try/finally so consoleErrorSpy.mockRestore() and
consoleWarnSpy.mockRestore() always run. Locate the test block (it(...) with the
two spies), move the assertions inside a try and place both mockRestore() calls
in the finally block to guarantee cleanup even if assertions throw.
- Line 21: The test file currently assigns globalThis.fetch at module scope
(globalThis.fetch = vi.fn()), which can leak between tests; change this to stub
and restore per test by calling vi.stubGlobal('fetch', vi.fn()) inside a
beforeEach and calling vi.unstubAllGlobals() inside an afterEach so each test
gets an isolated fetch mock; update AcceptInvitePage.spec.tsx to remove the
module-level assignment and place setup/teardown around the tests referencing
the same global fetch mock.

In `@apps/web/src/shared/lib/auth/AuthProvider.spec.tsx`:
- Line 41: The Logout button JSX currently uses <button onClick={() =>
logout()}>Logout</button> and omits an explicit type, so change that button
element to include type="button" to prevent it defaulting to "submit"; update
the JSX that renders the logout button (the element invoking the logout
function) to <button type="button" ...> so the logout handler remains unchanged.
- Around line 215-231: The test currently overrides globalThis.location then
restores it but leaves a window where an exception could skip restoration; wrap
the override and test interaction (the block that sets globalThis.location,
finds logoutBtn, clicks it, and asserts authClient.signOut) in a try/finally so
originalLocation is always restored, or move the override/restore into
beforeEach/afterEach; specifically update the block that sets
globalThis.location, uses screen.getByText('Logout') / logoutBtn.click(), and
expects authClient.signOut toHaveBeenCalled to ensure originalLocation is reset
in finally or afterEach.
- Line 147: The test creates a spy named consoleSpy via vi.spyOn(console,
'error') but never asserts on it; either remove the spy line if no error logging
is expected, or add an assertion that verifies expected logging (e.g.,
expect(consoleSpy).toHaveBeenCalled() or
expect(consoleSpy).toHaveBeenCalledWith(<expectedMessage>)) and keep the spy
restoration. Locate the spy in AuthProvider.spec.tsx (consoleSpy /
vi.spyOn(console, 'error')) and apply the chosen option so the spy is either
used in an assertion or removed to avoid unused-test noise.

In `@apps/web/vitest.config.ts`:
- Around line 31-39: The current vitest exclude patterns remove critical auth
code (AuthRoutes.tsx, src/main.tsx, src/app/App.tsx and the AuthProvider
bootstrap) which prevents measuring auth coverage; update the exclude list in
apps/web/vitest.config.ts to remove 'src/app/routes/**', 'src/main.tsx', and
'src/app/App.tsx' and instead only exclude type definitions and non-source
files—keep patterns like 'src/vite-env.d.ts' and 'src/**/*.d.ts', test files
(e.g. 'src/**/*.{spec,test}.{ts,tsx}'), shared presentational UI wrappers
('src/shared/components/ui/**') and barrel exports ('src/**/index.ts') so
AuthRoutes.tsx, AuthProvider initialization, and entry points remain in coverage
scope.

Comment thread apps/web/src/modules/identity/pages/AcceptInvitePage.spec.tsx Outdated
Comment thread apps/web/src/modules/identity/pages/AcceptInvitePage.spec.tsx
<div data-testid="org-name">{user.organizationName || 'No Org'}</div>
</>
)}
<button onClick={() => logout()}>Logout</button>

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.

🧹 Nitpick | 🔵 Trivial

Add explicit type="button" to prevent unintended form submission.

The button lacks an explicit type, defaulting to type="submit". While unlikely to cause issues in this test context, it's good practice to fix it to avoid test flakiness if the component is ever wrapped in a form.

🔧 Proposed fix
-            <button onClick={() => logout()}>Logout</button>
+            <button type="button" onClick={() => logout()}>Logout</button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button onClick={() => logout()}>Logout</button>
<button type="button" onClick={() => logout()}>Logout</button>
🧰 Tools
🪛 Biome (2.3.13)

[error] 41-41: Provide an explicit type prop for the button element.

The default type of a button is submit, which causes the submission of a form when placed inside a form element. This is likely not the behaviour that you want inside a React application.
Allowed button types are: submit, button or reset

(lint/a11y/useButtonType)

🤖 Prompt for AI Agents
In `@apps/web/src/shared/lib/auth/AuthProvider.spec.tsx` at line 41, The Logout
button JSX currently uses <button onClick={() => logout()}>Logout</button> and
omits an explicit type, so change that button element to include type="button"
to prevent it defaulting to "submit"; update the JSX that renders the logout
button (the element invoking the logout function) to <button type="button" ...>
so the logout handler remains unchanged.

});

it('should fallback to auto-provisioning if no tenants found', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });

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.

🧹 Nitpick | 🔵 Trivial

Unused consoleSpy — either add assertion or remove.

The spy is created and restored but never used for assertions. If the provisioning flow is expected to log errors, add an expectation; otherwise, remove the spy to reduce noise.

🔧 Option A: Add assertion if errors are expected
         await waitFor(() => {
             expect(screen.getByTestId('org-name')).toHaveTextContent('New Org');
         });
+        // Optionally assert no unexpected errors were logged
+        expect(consoleSpy).not.toHaveBeenCalled();
         consoleSpy.mockRestore();
🔧 Option B: Remove if not needed
     it('should fallback to auto-provisioning if no tenants found', async () => {
-        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
         // 1. Initial Session: No Org
         ...
-        consoleSpy.mockRestore();
     });
🤖 Prompt for AI Agents
In `@apps/web/src/shared/lib/auth/AuthProvider.spec.tsx` at line 147, The test
creates a spy named consoleSpy via vi.spyOn(console, 'error') but never asserts
on it; either remove the spy line if no error logging is expected, or add an
assertion that verifies expected logging (e.g.,
expect(consoleSpy).toHaveBeenCalled() or
expect(consoleSpy).toHaveBeenCalledWith(<expectedMessage>)) and keep the spy
restoration. Locate the spy in AuthProvider.spec.tsx (consoleSpy /
vi.spyOn(console, 'error')) and apply the chosen option so the spy is either
used in an assertion or removed to avoid unused-test noise.

Comment thread apps/web/src/shared/lib/auth/AuthProvider.spec.tsx Outdated
Comment thread apps/web/vitest.config.ts
Comment on lines +31 to +39
'src/shared/components/ui/**',
'src/vite-env.d.ts',
'src/main.tsx',
'src/app/App.tsx',
'src/app/routes/**',
'src/types/**',
'src/**/*.d.ts',
'**/types.ts',
'**/*.validation.ts',

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the repo structure and find the vitest config
find apps/web -name "vitest.config.ts" -o -name "vitest.config.js" | head -5

Repository: pramodnarayana/nexiom

Length of output: 90


🏁 Script executed:

# Check the vitest.config.ts file to see the full context and surrounding config
cat -n apps/web/vitest.config.ts

Repository: pramodnarayana/nexiom

Length of output: 1755


🏁 Script executed:

# Check if there's a git history to understand why these exclusions were added
cd apps/web && git log --oneline vitest.config.ts | head -10

Repository: pramodnarayana/nexiom

Length of output: 145


🏁 Script executed:

# Let's examine some of the excluded files to see if they contain runtime logic
echo "=== Checking src/main.tsx ===" && head -30 apps/web/src/main.tsx
echo -e "\n=== Checking src/app/App.tsx ===" && head -30 apps/web/src/app/App.tsx
echo -e "\n=== Sample route file ===" && find apps/web/src/app/routes -type f -name "*.tsx" -o -name "*.ts" | head -1 | xargs head -30

Repository: pramodnarayana/nexiom

Length of output: 2411


🏁 Script executed:

# Check a couple of files to understand the patterns
echo "=== Checking types pattern ===" && find apps/web/src/types -type f | head -3 | xargs head -20
echo -e "\n=== Checking validation pattern ===" && find apps/web -name "*.validation.ts" 2>/dev/null | head -1 | xargs cat

Repository: pramodnarayana/nexiom

Length of output: 379


🏁 Script executed:

# Let's check if there are any test files in the excluded directories
echo "=== Test files in routes ===" && find apps/web/src/app/routes -name "*.test.ts*" -o -name "*.spec.ts*" | wc -l
echo "=== Test files in types ===" && find apps/web/src/types -name "*.test.ts*" -o -name "*.spec.ts*" | wc -l
echo "=== Test files in ui components ===" && find apps/web/src/shared/components/ui -name "*.test.ts*" -o -name "*.spec.ts*" | wc -l

Repository: pramodnarayana/nexiom

Length of output: 163


🏁 Script executed:

# Search more thoroughly for validation files
find apps/web -name "*.validation.ts" -o -name "*.validation.tsx" 2>/dev/null

Repository: pramodnarayana/nexiom

Length of output: 47


🏁 Script executed:

# Check the UI components directory - are they library or app-specific?
ls -la apps/web/src/shared/components/ui/ | head -20
echo "---"
# Show a sample file
find apps/web/src/shared/components/ui -type f -name "*.tsx" -o -name "*.ts" | head -1 | xargs cat | head -30

Repository: pramodnarayana/nexiom

Length of output: 2074


🏁 Script executed:

# Check git diff for the PR to understand what changed and why
cd apps/web && git diff HEAD~1 vitest.config.ts

Repository: pramodnarayana/nexiom

Length of output: 253


🏁 Script executed:

# Check if there are any auth-related files in the excluded patterns
find apps/web/src/app/routes -type f \( -name "*.tsx" -o -name "*.ts" \) | xargs grep -l "auth\|Auth" 2>/dev/null | head -5
find apps/web/src -name "*.validation.ts" | xargs grep -l "auth\|Auth" 2>/dev/null

Repository: pramodnarayana/nexiom

Length of output: 268


🏁 Script executed:

# Check if AuthRoutes.tsx contains auth-related logic
cat -n apps/web/src/app/routes/AuthRoutes.tsx | head -40

Repository: pramodnarayana/nexiom

Length of output: 1895


🏁 Script executed:

# Check AppRouter.tsx for auth integration
cat -n apps/web/src/app/routes/AppRouter.tsx | head -40

Repository: pramodnarayana/nexiom

Length of output: 962


Excluding auth-related routes and entry points directly undermines the PR's goal to improve auth coverage.

The src/app/routes/** pattern excludes AuthRoutes.tsx, which contains login, signup, email verification, and auth callback routes—core to auth coverage. Additionally, excluding src/main.tsx removes AuthProvider initialization and src/app/App.tsx removes routing bootstrap from coverage measurement. This contradicts the PR objective stated in the commit ("add auth provider tests, fix coverage, and pass quality gate").

Consider narrowing exclusions to only:

  • src/vite-env.d.ts and src/**/*.d.ts (type definitions)
  • src/**/*.{spec,test}.{ts,tsx} (test files)
  • src/shared/components/ui/** (library component wrappers—mostly presentational)
  • src/**/index.ts (barrel exports)

Keep auth routes, entry points, and providers in coverage scope to align with PR objectives.

🤖 Prompt for AI Agents
In `@apps/web/vitest.config.ts` around lines 31 - 39, The current vitest exclude
patterns remove critical auth code (AuthRoutes.tsx, src/main.tsx,
src/app/App.tsx and the AuthProvider bootstrap) which prevents measuring auth
coverage; update the exclude list in apps/web/vitest.config.ts to remove
'src/app/routes/**', 'src/main.tsx', and 'src/app/App.tsx' and instead only
exclude type definitions and non-source files—keep patterns like
'src/vite-env.d.ts' and 'src/**/*.d.ts', test files (e.g.
'src/**/*.{spec,test}.{ts,tsx}'), shared presentational UI wrappers
('src/shared/components/ui/**') and barrel exports ('src/**/index.ts') so
AuthRoutes.tsx, AuthProvider initialization, and entry points remain in coverage
scope.

@pramodnarayana
pramodnarayana force-pushed the fix/tenant-provisioning-hook branch from c834e03 to a834738 Compare February 5, 2026 09:00

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/modules/identity/pages/ForgotPasswordPage.spec.tsx (1)

55-71: 🧹 Nitpick | 🔵 Trivial

Ensure console spy is restored even if the test fails.

If the assertion inside waitFor throws, consoleSpy.mockRestore() won't execute, potentially leaving console.error mocked for subsequent tests. Wrap the test body in try/finally or use afterEach for cleanup.

♻️ Proposed fix using try/finally
     it('handles submission error', async () => {
         const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
-        (authClient.requestPasswordReset as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
-
-        renderComponent();
-
-        const emailInput = screen.getByPlaceholderText(/email/i);
-        const submitBtn = screen.getByRole('button', { name: /send reset link/i });
-
-        fireEvent.change(emailInput, { target: { value: 'fail@example.com' } });
-        fireEvent.click(submitBtn);
-
-        await waitFor(() => {
-            expect(screen.getByText('Network error')).toBeInTheDocument();
-        });
-        consoleSpy.mockRestore();
+        try {
+            (authClient.requestPasswordReset as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
+
+            renderComponent();
+
+            const emailInput = screen.getByPlaceholderText(/email/i);
+            const submitBtn = screen.getByRole('button', { name: /send reset link/i });
+
+            fireEvent.change(emailInput, { target: { value: 'fail@example.com' } });
+            fireEvent.click(submitBtn);
+
+            await waitFor(() => {
+                expect(screen.getByText('Network error')).toBeInTheDocument();
+            });
+        } finally {
+            consoleSpy.mockRestore();
+        }
     });
apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts (1)

4-10: 🧹 Nitpick | 🔵 Trivial

Use SYSTEM_TENANT_ID constant instead of hardcoded UUID.

The test hardcodes '00000000-0000-0000-0000-000000000000' while other identity constants (PLATFORM_ADMIN_ROLE_ID, DEFAULT_SYSTEM_ROLE_ID) are already imported from @nexiom/identity. Import and use SYSTEM_TENANT_ID for consistency and maintainability.

♻️ Proposed fix
 import {
   AUTH_PROVIDER,
   USER_PROVIDER,
   TENANT_PROVIDER,
   PLATFORM_ADMIN_ROLE_ID,
   DEFAULT_SYSTEM_ROLE_ID,
+  SYSTEM_TENANT_ID,
 } from '@nexiom/identity';

Then update the assertions:

       expect(mockUserProvider.findAll).toHaveBeenCalledWith({
         page: 1,
         limit: 10,
-        tenantId: '00000000-0000-0000-0000-000000000000',
+        tenantId: SYSTEM_TENANT_ID,
       });

Also applies to: 83-83, 95-95

packages/identity/src/schema.ts (1)

159-173: ⚠️ Potential issue | 🔴 Critical

Database migration for organization.isSystem column is missing and must be created before deploying.

The schema defines isSystem for the organization table, but the migration snapshot does not include this column for the organization table, and the only migration file (0000_sudden_silver_surfer.sql) does not add it. This mismatch will cause runtime failures when the app attempts to read or write organization records. Generate a new migration using drizzle-kit generate to add the isSystem column with a NOT NULL constraint and default value of false, then verify it is applied.

🤖 Fix all issues with AI agents
In `@apps/api/src/db/reset-e2e.ts`:
- Around line 35-41: The catch block around the TRUNCATE loop is swallowing the
error; change catch (_e) to catch (e) and log the actual error (e) instead of
only a summary so permission/connection issues are visible — update the handler
that currently calls console.log(`Skipped ${table}...`) to call console.error
(or your existing logger) with both the descriptive message and the error object
(referencing client, table, and the catch variable e) so real failures are
surfaced for debugging.
- Around line 66-67: The permissions array in reset-e2e.ts contains a duplicate
entry 'dashboard:read'; remove the extra 'dashboard:read' string from the
permissions array so each permission is unique (leave a single 'dashboard:read'
entry), and verify any code that builds the SQL INSERT for permissions (the
permissions array variable) still composes correctly—no other changes required.
- Around line 113-123: Replace the real-looking test email with a reserved
example domain in the seed data: update the email variable (currently set to
'pramod.narayana@gmail.com') to use an RFC 2606 reserved address such as
'pramod.narayana@example.com' (or a fully synthetic one like 'user@example.com')
so the INSERT in client.query for seeding the "user" row uses a non-PII/test
domain; ensure any other hard-coded test emails in reset-e2e.ts follow the same
pattern.
- Around line 10-18: Add a production safeguard at the start of the main
function (after the existing DATABASE_URL check) to abort execution if running
against production: check process.env.NODE_ENV === 'production' OR if the
connection string in process.env.DATABASE_URL looks like a prod DB (e.g.,
contains "prod" or your prod hostname), log a clear error and process.exit(1);
allow an explicit override only via a deliberate env flag like FORCE_RESET=true
to proceed. Ensure this guard runs before any truncation logic or calls that
modify the database so functions such as main and any truncate/cleanup helpers
cannot run against production accidentally.
- Around line 163-173: Remove the redundant INSERT of the 'platform_admin' role:
delete the INSERT INTO "role" (...) VALUES ('platform_admin', ...) ON CONFLICT
DO NOTHING; block (and the surrounding comment claiming owner/admin/member only)
since platform_admin is already created earlier; retain the sysMemberId
declaration but update the comment near sysMemberId to accurately reflect which
roles were seeded (including platform_admin) to avoid confusion.

In `@apps/web/src/app/providers/access-control-provider.spec.ts`:
- Around line 119-127: Add a test that verifies errors from
authProvider.getPermissions propagate through accessControlProvider.can by
mocking vi.mocked(authProvider.getPermissions!).mockRejectedValue(new
Error('Session expired')) and asserting await expect(accessControlProvider.can({
resource: 'users', action: 'read' })).rejects.toThrow('Session expired'); ensure
the test uses the same pattern as existing tests (vi.mocked, async/await) and
targets the can method on accessControlProvider.

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 149-159: The error handling in AuthProvider's refreshSession catch
block extracts status from err.status/statusCode but misses Axios errors where
status is at err.response.status; update the errorStatus extraction to first
check (err as AxiosError)?.response?.status, then fall back to (err as
{status?:number;statusCode?:number})?.status || ?.statusCode, and keep the
existing logic to call setToken(undefined) and setUser(null) when errorStatus
=== 401; ensure this uses the same catch block where setIsLoading(false) is set
so loading state remains correct.

In `@packages/identity/src/identity.module.spec.ts`:
- Around line 100-102: The test looks up the provider using the hardcoded string
"IDENTITY_OPTIONS"; change the predicate in the providers.find call to compare
against the imported constant IDENTITY_OPTIONS (i.e., (p) => p.provide ===
IDENTITY_OPTIONS) and ensure IDENTITY_OPTIONS is imported at the top of the spec
so the provider lookup remains consistent if the constant value changes.

In `@packages/identity/vitest.config.ts`:
- Around line 25-29: The branch coverage threshold change in vitest.config.ts
lowered thresholds.branches from 80 to 79; restore thresholds.branches to 80 to
keep the original quality gate, or if this is a temporary relaxation add a
tracked justification by creating and linking a follow‑up issue/ticket and
reference that issue ID in a comment near the thresholds block (in the same
file) explaining why the threshold is reduced and when it will be reverted;
ensure the symbol thresholds and key branches are updated accordingly.

Comment thread apps/api/src/db/reset-e2e.ts Outdated
Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/api/src/db/reset-e2e.ts Outdated
Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/api/src/db/reset-e2e.ts Outdated
Comment thread apps/web/src/app/providers/access-control-provider.spec.ts
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx
Comment thread packages/identity/src/identity.module.spec.ts
Comment on lines 25 to 29
thresholds: {
statements: 80,
branches: 80,
branches: 79,
functions: 70,
lines: 80,

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.

⚠️ Potential issue | 🟠 Major

Don’t lower the branch coverage gate without a tracked justification.

Line 27 reduces branch coverage from 80 → 79, which weakens the quality gate and conflicts with the PR’s stated coverage goals. If this is temporary, document a follow-up issue; otherwise, keep the threshold at 80.

✅ Suggested fix
-        branches: 79,
+        branches: 80,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
thresholds: {
statements: 80,
branches: 80,
branches: 79,
functions: 70,
lines: 80,
thresholds: {
statements: 80,
branches: 80,
functions: 70,
lines: 80,
🤖 Prompt for AI Agents
In `@packages/identity/vitest.config.ts` around lines 25 - 29, The branch coverage
threshold change in vitest.config.ts lowered thresholds.branches from 80 to 79;
restore thresholds.branches to 80 to keep the original quality gate, or if this
is a temporary relaxation add a tracked justification by creating and linking a
follow‑up issue/ticket and reference that issue ID in a comment near the
thresholds block (in the same file) explaining why the threshold is reduced and
when it will be reverted; ensure the symbol thresholds and key branches are
updated accordingly.

…istency

- reset-e2e.ts: Add production safeguard checking NODE_ENV and connection string
- reset-e2e.ts: Fix error logging to show actual errors instead of swallowing them
- reset-e2e.ts: Remove duplicate 'dashboard:read' permission
- reset-e2e.ts: Use RFC 2606 compliant email (example.com instead of gmail.com)
- reset-e2e.ts: Remove redundant platform_admin INSERT (already in step 2)
- access-control-provider.spec.ts: Add test for error propagation from getPermissions
- AuthProvider.tsx: Fix Axios error status extraction (check response.status first)
- identity.module.spec.ts: Use imported IDENTITY_OPTIONS constant instead of hardcoded string

All tests passing: 209 tests (181 API + 28 web)

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/api/src/db/reset-e2e.ts`:
- Around line 194-206: Replace the hardcoded customer email using a real domain:
update the value assigned to customerEmail (and any direct SQL INSERT values
referencing it) from 'customer@acme.com' to use an RFC 2606 reserved test domain
such as 'customer@example.com'; ensure the variable customerEmail and the INSERT
into "user" (using customerUserId and customerEmail) are the only places changed
so tests and seed data remain consistent without using a real domain.

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 11-16: The local Tenant interface in AuthProvider.tsx diverges
from the shared Tenant in packages/identity/src/interfaces/types.ts; replace the
local definition by importing the shared Tenant type (e.g., import type { Tenant
} from '@nexiom/identity') and adapt code that relies on createdAt (e.g., parse
dates in the sorting comparator with new
Date(String(tenant.createdAt)).getTime()), or alternatively introduce a
TenantResponse type that matches the API (createdAt: string plus any
response-only fields) and transform TenantResponse -> Tenant (parsing createdAt
to Date and filling missing fields) before use; update references in
AuthProvider functions (e.g., the tenant list/sort logic) accordingly.
- Around line 176-197: The login callback currently fire-and-forgets
refreshSession causing transient incomplete UI state; change login to return the
refreshSession promise (make login async or return refreshSession()) so callers
can await full hydration, update the AuthContextType signature to reflect login:
(data) => Promise<void>, and ensure any callers of login are updated to handle
the returned promise (or awaited) to avoid UI flashes from the minimal user
state set in login before refreshSession completes.

Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@apps/api/src/db/reset-e2e.ts`:
- Around line 132-156: Replace the real-person PII used in the seed inserts:
change the literal email 'pramod.narayana@example.com' (variable email) and the
name 'Pramod Narayana' in the "user" INSERT to generic placeholders (e.g.,
test.user+e2e@example.com and 'Test User'), and ensure the same generic
placeholder is used for the account INSERT parameters (the $3 value currently
passed as email). Update any other occurrences noted (the later seed at lines
referenced, e.g., variables used around account seeding at the second INSERT
block) so no real-person identifiers remain in variables email, name, or any
hard-coded strings in client.query.
- Around line 226-241: The seed script currently prints plaintext credentials
via console.log in the main function (variables email, passwordStart,
customerEmail); remove or gate printing of actual passwords by either omitting
the passwords entirely (log only usernames/orgs) or conditionally showing them
based on an explicit opt-in flag/env var (e.g., SHOW_CREDENTIALS) checked inside
main before logging; alternatively, mask passwords (e.g., show only last 4
chars) when logging so no full secret appears in CI logs.
- Around line 63-131: The admin role ('admin' inserted in the "role" table) is
created but never granted permissions; update the seeding to assign non-system
permissions to 'admin' by iterating over the existing perms array and inserting
into "role_permission" for roleId 'admin' only for permissions that do not start
with 'system_' (i.e., filter out perms where p.startsWith('system_')), using the
same INSERT ... ON CONFLICT DO NOTHING pattern used for 'owner'/'platform_admin'
and 'member' to avoid duplicates.
- Around line 35-38: Move the await client.connect() call into the existing try
block so connection errors are caught by the catch handler; specifically, in
reset-e2e.ts relocate the await client.connect() that currently runs before the
try to inside the try surrounding the seeding logic (where the catch logs "Seed
failed"), ensuring the client variable and subsequent await client.end() are
still used appropriately.

In `@apps/web/package.json`:
- Line 16: The `@nexiom/identity` package is backend-only (uses `@nestjs/`* and
drizzle-orm) and not browser-ready; either remove it from the web app's
dependencies or replace it with a browser-safe identity package: 1) If you want
identity in the web app, create a new browser-compatible package (e.g.,
`@nexiom/identity-browser`) that has no Node/backend deps, uses ESM (add
"type":"module" and an "exports" field with an ESM entrypoint), and exposes only
browser-safe APIs; update the web app to depend on that package instead of
`@nexiom/identity`. 2) If identity belongs only on the server, remove
`@nexiom/identity` from the web app manifest and ensure it remains listed only in
backend package.jsons. Ensure any imports in the web app point to the new
browser package or are removed.

In `@apps/web/src/shared/lib/auth/AuthProvider.tsx`:
- Around line 60-145: The current logic calls authClient.getSession() but
ignores the returned error and immediately clears auth state when data is null;
change the block around authClient.getSession() to capture the returned error
(rename _error -> sessionError), and only clear token/user (call
setToken(undefined); setUser(null)) when sessionError?.status === 401 (or when
the API explicitly indicates an unauthorized session). For other sessionError
values (network/5xx/null), do not clear auth state—log the transient error,
setIsLoading(false), and return/keep existing user so we don't log out users on
transient failures; keep existing hydrateUser paths unchanged for when data
exists or when provisioning succeeds. Ensure references:
authClient.getSession(), sessionError (new variable), hydrateUser, setToken,
setUser, setIsLoading, and provisionAttemptsRef.current are used as described.

In `@apps/web/tsconfig.app.json`:
- Around line 25-31: The project adds a TypeScript path alias "@nexiom/identity"
but Vite and Vitest won't resolve it at runtime; update the Vite and Vitest
configs to match the tsconfig paths by adding the "@nexiom/identity" mapping to
resolve.alias in vite.config.ts and to test.alias in vitest.config.ts (or
alternatively install and enable the vite-tsconfig-paths plugin in
vite.config.ts so Vite/Vitest automatically pick up tsconfig.app.json paths);
target the resolve.alias configuration block in vite.config.ts and the
test.alias block in vitest.config.ts (or the plugin registration area) to
implement the fix.

In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 100-107: The matcher function currently uses
context.path.includes("/callback/") which is too broad; update the matcher in
better-auth.adapter.ts (the matcher function referencing context.path and the
path.endsWith checks for "/sign-up/email" and "/sign-in/email") to use a more
precise check such as path.startsWith("/callback/") (or another exact prefix
check) instead of includes, so only paths that actually begin with the callback
prefix match.

Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/api/src/db/reset-e2e.ts
Comment thread apps/web/package.json Outdated
Comment thread apps/web/src/shared/lib/auth/AuthProvider.tsx
Comment on lines 25 to 31
"paths": {
"@/*": [
"./src/*"
],
"@nexiom/identity": [
"../../packages/identity/src/index.ts"
]

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find Vite/Vitest configs and check for tsconfig paths or alias setup
echo "=== Checking Vite configs ==="
fd -a 'vite\.config\.(ts|mts|js|cjs)$' -x cat -n {}

echo -e "\n=== Checking Vitest configs ==="
fd -a 'vitest\.config\.(ts|mts|js|cjs)$' -x cat -n {}

echo -e "\n=== Searching for tsconfigPaths or alias in config files ==="
fd -a '(vite|vitest)\.config\.(ts|mts|js|cjs)$' -x grep -n -i 'tsconfigPaths\|alias' {}

Repository: pramodnarayana/nexiom

Length of output: 6099


Add @nexiom/identity alias to runtime configs.

The path alias in tsconfig.app.json only affects TypeScript type-checking. The Vite bundler and Vitest test runner won't resolve @nexiom/identity at runtime—it's missing from both apps/web/vite.config.ts (which only defines @ → ./src) and the test configuration.

Add the alias to vite.config.ts under resolve.alias and to vitest.config.ts under test.alias, or use the vite-tsconfig-paths plugin to automatically resolve tsconfig paths.

🤖 Prompt for AI Agents
In `@apps/web/tsconfig.app.json` around lines 25 - 31, The project adds a
TypeScript path alias "@nexiom/identity" but Vite and Vitest won't resolve it at
runtime; update the Vite and Vitest configs to match the tsconfig paths by
adding the "@nexiom/identity" mapping to resolve.alias in vite.config.ts and to
test.alias in vitest.config.ts (or alternatively install and enable the
vite-tsconfig-paths plugin in vite.config.ts so Vite/Vitest automatically pick
up tsconfig.app.json paths); target the resolve.alias configuration block in
vite.config.ts and the test.alias block in vitest.config.ts (or the plugin
registration area) to implement the fix.

Comment thread packages/identity/src/adapters/better-auth.adapter.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/identity/src/adapters/better-auth.adapter.ts (1)

444-531: ⚠️ Potential issue | 🟡 Minor

Normalize nullable role before returning Invitation.

Validation allows role to be null, but the return path casts it to string, which can leak null into a string-typed field and diverge from mapInvitation defaults. Prefer a safe default (or update the Invitation type to allow null explicitly).

✅ Normalize role
-      role: invData.role as string,
+      role: (invData.role ?? "user") as string,
🤖 Fix all issues with AI agents
In `@packages/identity/src/adapters/better-auth.adapter.ts`:
- Around line 132-141: Replace the full list lookup with a single-tenant
existence check: in the block inside better-auth.adapter.ts where you call
this.tenantProvider.findAllForUser(user.id) and check existing.length, use
this.tenantProvider.findOneForUser(user.id) (or the equivalent single-item
method on ITenantProvider) and test for null/undefined; only call
this.tenantProvider.provisionTenantForUser(user.id) when findOneForUser returns
no tenant. Keep the surrounding idempotent/provisioning logic (including the
try/catch and user.id guard) intact.

Comment on lines +132 to +141
if (user?.id) {
try {
// Idempotent Check: Handled by provisionTenantForUser logic
// We check existence to avoid redundant DB calls/logs
const existing = await this.tenantProvider.findAllForUser(
user.id,
);
if (existing.length === 0) {
try {
await this.tenantProvider.provisionTenantForUser(user.id);

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.

🧹 Nitpick | 🔵 Trivial

Prefer single-tenant lookup to avoid full list fetch.

The hook only needs an existence check; if ITenantProvider exposes findOneForUser, use it to reduce DB work per login.

♻️ Suggested change
-                  const existing = await this.tenantProvider.findAllForUser(
-                    user.id,
-                  );
-                  if (existing.length === 0) {
+                  const existing = await this.tenantProvider.findOneForUser(
+                    user.id,
+                  );
+                  if (!existing) {
🤖 Prompt for AI Agents
In `@packages/identity/src/adapters/better-auth.adapter.ts` around lines 132 -
141, Replace the full list lookup with a single-tenant existence check: in the
block inside better-auth.adapter.ts where you call
this.tenantProvider.findAllForUser(user.id) and check existing.length, use
this.tenantProvider.findOneForUser(user.id) (or the equivalent single-item
method on ITenantProvider) and test for null/undefined; only call
this.tenantProvider.provisionTenantForUser(user.id) when findOneForUser returns
no tenant. Keep the surrounding idempotent/provisioning logic (including the
try/catch and user.id guard) intact.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/identity/src/adapters/drizzle-tenant.adapter.ts`:
- Around line 204-232: findOneForUser returns a non-deterministic tenant when
tenantId is undefined because the SQL has no ORDER BY; update the query in
findOneForUser to add a deterministic ordering (for example order by
schema.organization.createdAt DESC or schema.organization.id) before .limit(1)
so the same tenant is returned consistently; locate the select chain starting on
this.db.select(...).from(schema.organization).innerJoin(...) and append a
.orderBy(...) (or equivalent ordering method supported by the drizzle client)
using schema.organization.createdAt or schema.organization.id.

Comment thread packages/identity/src/adapters/drizzle-tenant.adapter.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/identity/src/adapters/drizzle-tenant.adapter.ts`:
- Around line 204-225: In findOneForUser add a deterministic tie-breaker to the
query ordering: when ordering by schema.organization.createdAt (in the query
inside findOneForUser) also order by schema.organization.id (secondary order) so
results are stable when createdAt values match; update the .orderBy(...) call to
include desc(schema.organization.createdAt) then desc(schema.organization.id)
(or appropriate ascending/descending for id) so the .limit(1) always returns a
deterministic row.

Comment thread packages/identity/src/adapters/drizzle-tenant.adapter.ts
@pramodnarayana
pramodnarayana merged commit 97d2c28 into development Feb 5, 2026
2 checks passed
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