[GH-ISSUE #9135] organization createInvitation type rejects dynamic roles while runtime accepts them #28607

Open
opened 2026-04-17 20:02:41 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @husseinraoouf on GitHub (Apr 12, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9135

Summary

When using the organization plugin with dynamic access control enabled, auth.api.createInvitation() accepts dynamic organization roles at runtime, but its TypeScript type only allows statically configured role keys.

Version

better-auth 1.6.0

Expected

createInvitation should allow role: string | string[] when dynamic access control is enabled, or otherwise expose a type that includes dynamically created organization roles.

Actual

TypeScript rejects dynamic role strings passed to auth.api.createInvitation({ body: { role } }), even though the runtime implementation validates them against the organization role table and accepts them when they exist.

Why this seems like a typing issue

The docs show role: string | string[] for the invite endpoint.
The runtime code in dist/plugins/organization/routes/crud-invites.mjs checks unknown roles against dynamic org roles when dynamic access control is enabled.
In practice, this works at runtime for DB-backed custom roles.
But the distributed TS type for createInvitation appears to infer only static keys from the configured roles object.

Reproduction shape

Server config:

  • organization plugin enabled
  • dynamic access control enabled
  • custom org roles are created dynamically in the DB

Then on the server:

await auth.api.createInvitation({
  headers,
  body: {
    email: "user@example.com",
    role: "customRoleFromDb",
    organizationId: "org-id",
  },
});

Result

  • TypeScript error on role
  • Runtime accepts the role if it exists in the org role table

Notes

I had to locally work around this with a narrow cast only on the role field, because the runtime behavior is correct for dynamic roles.

If helpful, I can also provide a minimal reproduction repo, but the mismatch was reproducible directly against 1.6.0 in a NestJS backend using the server auth.api.createInvitation call.

Originally created by @husseinraoouf on GitHub (Apr 12, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9135 ## Summary When using the organization plugin with dynamic access control enabled, `auth.api.createInvitation()` accepts dynamic organization roles at runtime, but its TypeScript type only allows statically configured role keys. ## Version `better-auth` 1.6.0 ## Expected `createInvitation` should allow `role: string | string[]` when dynamic access control is enabled, or otherwise expose a type that includes dynamically created organization roles. ## Actual TypeScript rejects dynamic role strings passed to `auth.api.createInvitation({ body: { role } })`, even though the runtime implementation validates them against the organization role table and accepts them when they exist. ## Why this seems like a typing issue The docs show `role: string | string[]` for the invite endpoint. The runtime code in `dist/plugins/organization/routes/crud-invites.mjs` checks unknown roles against dynamic org roles when dynamic access control is enabled. In practice, this works at runtime for DB-backed custom roles. But the distributed TS type for `createInvitation` appears to infer only static keys from the configured `roles` object. ## Reproduction shape Server config: - organization plugin enabled - dynamic access control enabled - custom org roles are created dynamically in the DB Then on the server: ```ts await auth.api.createInvitation({ headers, body: { email: "user@example.com", role: "customRoleFromDb", organizationId: "org-id", }, }); ``` ## Result - TypeScript error on `role` - Runtime accepts the role if it exists in the org role table ## Notes I had to locally work around this with a narrow cast only on the `role` field, because the runtime behavior is correct for dynamic roles. If helpful, I can also provide a minimal reproduction repo, but the mismatch was reproducible directly against 1.6.0 in a NestJS backend using the server `auth.api.createInvitation` call.
GiteaMirror added the organization label 2026-04-17 20:02:41 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 12, 2026):

This is a real typing gap. The Zod schema for createInvitation uses z.string() / z.array(z.string()) [1], so runtime accepts any string. But the TypeScript type is derived from InferOrganizationRolesFromOption<O>, which resolves to keyof O["roles"] — purely static keys [2]. When dynamicAccessControl is enabled, nothing currently widens that type.

At runtime, the handler already does the right thing: it checks static roles first, then queries the organizationRole table for any unknowns when dynamic access control is on [3]. So the safety net is there — the type just doesn't reflect it.

The fix would be something like conditionally widening the role type when dynamicAccessControl.enabled is true:

type RoleType<O> = O extends { dynamicAccessControl: { enabled: true } }
  ? string
  : InferOrganizationRolesFromOption<O>;

Several related PRs have improved typing around dynamic roles [4] [5], but this specific conditional widening hasn't been addressed yet.

For now, casting just the role field (role: myDynamicRole as any) is safe — runtime validation via the DB lookup provides the actual guarantees [6].

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4231512593 --> @dosubot[bot] commented on GitHub (Apr 12, 2026): <!-- Answer --> This is a real typing gap. The Zod schema for `createInvitation` uses `z.string()` / `z.array(z.string())` [[1]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/routes/crud-invites.ts#L27-L41), so runtime accepts any string. But the TypeScript type is derived from `InferOrganizationRolesFromOption<O>`, which resolves to `keyof O["roles"]` — purely static keys [[2]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/schema.ts#L374-L380). When `dynamicAccessControl` is enabled, nothing currently widens that type. At runtime, the handler already does the right thing: it checks static roles first, then queries the `organizationRole` table for any unknowns when dynamic access control is on [[3]](https://github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/routes/crud-invites.ts#L235-L263). So the safety net is there — the type just doesn't reflect it. The fix would be something like conditionally widening the role type when `dynamicAccessControl.enabled` is `true`: ```typescript type RoleType<O> = O extends { dynamicAccessControl: { enabled: true } } ? string : InferOrganizationRolesFromOption<O>; ``` Several related PRs have improved typing around dynamic roles [[4]](https://github.com/better-auth/better-auth/pull/6774) [[5]](https://github.com/better-auth/better-auth/pull/7359), but this specific conditional widening hasn't been addressed yet. For now, casting just the `role` field (`role: myDynamicRole as any`) is safe — runtime validation via the DB lookup provides the actual guarantees [[6]](https://github.com/better-auth/better-auth/issues/4463#issuecomment-3258654177). <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=f3e33a5a-edbe-44a0-b8a1-a4c96eb61950&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20real%20typing%20gap.%20The%20Zod%20schema%20for%20%60createInvitation%60%20uses%20%60z.string%28%29%60%20/%20%60z.array%28z.string%28%29%29%60%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/routes/crud-invites.ts%23L27-L41%29%2C%20so%20runtime%20accepts%20any%20string.%20But%20the%20TypeScript%20type%20is%20derived%20from%20%60InferOrganizationRolesFromOption%3CO%3E%60%2C%20which%20resolves%20to%20%60keyof%20O%5B%22roles%22%5D%60%20%E2%80%94%20purely%20static%20keys%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/schema.ts%23L374-L380%29.%20When%20%60dynamicAccessControl%60%20is%20enabled%2C%20nothing%20currently%20widens%20that%20type.%0A%0AAt%20runtime%2C%20the%20handler%20already%20does%20the%20right%20thing%3A%20it%20checks%20static%20roles%20first%2C%20then%20queries%20the%20%60organizationRole%60%20table%20for%20any%20unknowns%20when%20dynamic%20access%20control%20is%20on%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/ed2c18df3150ab3a024f7d14a5b5fa58719332f2/packages/better-auth/src/plugins/organization/routes/crud-invites.ts%23L235-L263%29.%20So%20the%20safety%20net%20is%20there%20%E2%80%94%20the%20type%20just%20doesn%27t%20reflect%20it.%0A%0AThe%20fix%20would%20be%20something%20like%20conditionally%20widening%20the%20role%20type%20when%20%60dynamicAccessControl.enabled%60%20is%20%60true%60%3A%0A%0A%60%60%60typescript%0Atype%20RoleType%3CO%3E%20%3D%20O%20extends%20%7B%20dynamicAccessControl%3A%20%7B%20enabled%3A%20true%20%7D%20%7D%0A%20%20%3F%20string%0A%20%20%3A%20InferOrganizationRolesFromOption%3CO%3E%3B%0A%60%60%60%0A%0ASeveral%20related%20PRs%20have%20improved%20typing%20around%20dynamic%20roles%20%5B%5B4%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/6774%29%20%5B%5B5%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/7359%29%2C%20but%20this%20specific%20conditional%20widening%20hasn%27t%20been%20addressed%20yet.%0A%0AFor%20now%2C%20casting%20just%20the%20%60role%60%20field%20%28%60role%3A%20myDynamicRole%20as%20any%60%29%20is%20safe%20%E2%80%94%20runtime%20validation%20via%20the%20DB%20lookup%20provides%20the%20actual%20guarantees%20%5B%5B6%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/4463%23issuecomment-3258654177%29.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/9135)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28607