@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,272 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { DuplicateLinkedIdentityError, type LinkedIdentityStore } from './linked-identity-store'
3
+ import type { LinkedIdentity, OAuthState, OAuthStateStore } from './oauth-types'
4
+
5
+ interface SqliteDatabase {
6
+ pragma(source: string): unknown
7
+ exec(source: string): void
8
+ prepare(source: string): {
9
+ run(...params: unknown[]): { changes?: number } | unknown
10
+ get(...params: unknown[]): unknown
11
+ all(...params: unknown[]): unknown[]
12
+ }
13
+ transaction<T>(fn: () => T): () => T
14
+ }
15
+
16
+ interface OAuthStateRow {
17
+ state: string
18
+ provider: string
19
+ redirect_uri: string
20
+ created_at: number
21
+ expires_at: number
22
+ metadata_json: string | null
23
+ code_verifier: string | null
24
+ }
25
+
26
+ interface LinkedIdentityRow {
27
+ id: string
28
+ user_id: string
29
+ provider: string
30
+ provider_user_id: string
31
+ email: string | null
32
+ linked_at: number
33
+ }
34
+
35
+ export class SqliteOAuthStateStore implements OAuthStateStore {
36
+ private readonly db: SqliteDatabase
37
+
38
+ constructor(db: SqliteDatabase) {
39
+ this.db = db
40
+ this.db.pragma('journal_mode = WAL')
41
+ this.ensureTables()
42
+ }
43
+
44
+ async store(state: OAuthState): Promise<void> {
45
+ this.db
46
+ .prepare(`
47
+ INSERT OR REPLACE INTO auth_oauth_states
48
+ (state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?)
50
+ `)
51
+ .run(
52
+ state.state,
53
+ state.provider,
54
+ state.redirectUri,
55
+ state.createdAt,
56
+ state.expiresAt,
57
+ state.metadata ? JSON.stringify(state.metadata) : null,
58
+ state.codeVerifier ?? null,
59
+ )
60
+ }
61
+
62
+ async consume(stateValue: string): Promise<OAuthState | null> {
63
+ const consumeInTransaction = this.db.transaction(() => {
64
+ const row = this.db
65
+ .prepare('SELECT * FROM auth_oauth_states WHERE state = ?')
66
+ .get(stateValue) as OAuthStateRow | undefined
67
+
68
+ if (!row) return null
69
+
70
+ this.db.prepare('DELETE FROM auth_oauth_states WHERE state = ?').run(stateValue)
71
+ if (Date.now() > row.expires_at) return null
72
+
73
+ return rowToOAuthState(row)
74
+ })
75
+
76
+ return consumeInTransaction()
77
+ }
78
+
79
+ async cleanExpired(): Promise<number> {
80
+ const result = this.db
81
+ .prepare('DELETE FROM auth_oauth_states WHERE expires_at < ?')
82
+ .run(Date.now()) as { changes?: number }
83
+ return result.changes ?? 0
84
+ }
85
+
86
+ private ensureTables(): void {
87
+ this.db.exec(`
88
+ CREATE TABLE IF NOT EXISTS auth_oauth_states (
89
+ state TEXT PRIMARY KEY,
90
+ provider TEXT NOT NULL,
91
+ redirect_uri TEXT NOT NULL,
92
+ created_at INTEGER NOT NULL,
93
+ expires_at INTEGER NOT NULL,
94
+ metadata_json TEXT,
95
+ code_verifier TEXT
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
99
+ ON auth_oauth_states(expires_at);
100
+ `)
101
+ }
102
+ }
103
+
104
+ export class SqliteLinkedIdentityStore implements LinkedIdentityStore {
105
+ private readonly db: SqliteDatabase
106
+
107
+ constructor(db: SqliteDatabase) {
108
+ this.db = db
109
+ this.db.pragma('journal_mode = WAL')
110
+ this.ensureTables()
111
+ }
112
+
113
+ async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
114
+ const row = this.db
115
+ .prepare(`
116
+ SELECT * FROM auth_linked_identities
117
+ WHERE provider = ? AND provider_user_id = ?
118
+ `)
119
+ .get(provider, providerUserId) as LinkedIdentityRow | undefined
120
+
121
+ return row ? rowToLinkedIdentity(row) : null
122
+ }
123
+
124
+ async findByUser(userId: string): Promise<LinkedIdentity[]> {
125
+ const rows = this.db
126
+ .prepare(`
127
+ SELECT * FROM auth_linked_identities
128
+ WHERE user_id = ?
129
+ ORDER BY linked_at ASC
130
+ `)
131
+ .all(userId) as LinkedIdentityRow[]
132
+
133
+ return rows.map(rowToLinkedIdentity)
134
+ }
135
+
136
+ async create(params: {
137
+ userId: string
138
+ provider: string
139
+ providerUserId: string
140
+ email: string | null
141
+ }): Promise<LinkedIdentity> {
142
+ const identity: LinkedIdentity = {
143
+ id: randomUUID(),
144
+ userId: params.userId,
145
+ provider: params.provider,
146
+ providerUserId: params.providerUserId,
147
+ email: params.email,
148
+ linkedAt: Date.now(),
149
+ }
150
+
151
+ try {
152
+ this.db
153
+ .prepare(`
154
+ INSERT INTO auth_linked_identities
155
+ (id, user_id, provider, provider_user_id, email, linked_at)
156
+ VALUES (?, ?, ?, ?, ?, ?)
157
+ `)
158
+ .run(
159
+ identity.id,
160
+ identity.userId,
161
+ identity.provider,
162
+ identity.providerUserId,
163
+ identity.email,
164
+ identity.linkedAt,
165
+ )
166
+ } catch (error) {
167
+ if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
168
+ throw new DuplicateLinkedIdentityError(params.provider)
169
+ }
170
+ throw error
171
+ }
172
+
173
+ return identity
174
+ }
175
+
176
+ async delete(userId: string, provider: string): Promise<void> {
177
+ this.db
178
+ .prepare('DELETE FROM auth_linked_identities WHERE user_id = ? AND provider = ?')
179
+ .run(userId, provider)
180
+ }
181
+
182
+ private ensureTables(): void {
183
+ this.db.exec(`
184
+ CREATE TABLE IF NOT EXISTS auth_linked_identities (
185
+ id TEXT PRIMARY KEY,
186
+ user_id TEXT NOT NULL,
187
+ provider TEXT NOT NULL,
188
+ provider_user_id TEXT NOT NULL,
189
+ email TEXT,
190
+ linked_at INTEGER NOT NULL,
191
+ UNIQUE(provider, provider_user_id),
192
+ UNIQUE(user_id, provider)
193
+ );
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
196
+ ON auth_linked_identities(user_id);
197
+ `)
198
+ }
199
+ }
200
+
201
+ export async function createSqliteOAuthStateStore(options: {
202
+ filename: string
203
+ }): Promise<SqliteOAuthStateStore> {
204
+ const Database = await loadBetterSqlite3()
205
+ const db = new Database(options.filename)
206
+ return new SqliteOAuthStateStore(db as unknown as SqliteDatabase)
207
+ }
208
+
209
+ export async function createSqliteLinkedIdentityStore(options: {
210
+ filename: string
211
+ }): Promise<SqliteLinkedIdentityStore> {
212
+ const Database = await loadBetterSqlite3()
213
+ const db = new Database(options.filename)
214
+ return new SqliteLinkedIdentityStore(db as unknown as SqliteDatabase)
215
+ }
216
+
217
+ export async function createSqliteOAuthStores(options: { filename: string }): Promise<{
218
+ stateStore: SqliteOAuthStateStore
219
+ linkedIdentityStore: SqliteLinkedIdentityStore
220
+ }> {
221
+ const Database = await loadBetterSqlite3()
222
+ const db = new Database(options.filename) as unknown as SqliteDatabase
223
+ return {
224
+ stateStore: new SqliteOAuthStateStore(db),
225
+ linkedIdentityStore: new SqliteLinkedIdentityStore(db),
226
+ }
227
+ }
228
+
229
+ async function loadBetterSqlite3(): Promise<new (filename: string) => unknown> {
230
+ try {
231
+ const { createRequire } = await import('node:module')
232
+ const require = createRequire(import.meta.url)
233
+ return require('better-sqlite3') as new (
234
+ filename: string,
235
+ ) => unknown
236
+ } catch {
237
+ throw new Error(
238
+ 'SQLite OAuth stores require the "better-sqlite3" package. Install it in your project dependencies.',
239
+ )
240
+ }
241
+ }
242
+
243
+ function rowToOAuthState(row: OAuthStateRow): OAuthState {
244
+ return {
245
+ state: row.state,
246
+ provider: row.provider,
247
+ redirectUri: row.redirect_uri,
248
+ createdAt: row.created_at,
249
+ expiresAt: row.expires_at,
250
+ metadata: parseMetadata(row.metadata_json),
251
+ codeVerifier: row.code_verifier ?? undefined,
252
+ }
253
+ }
254
+
255
+ function parseMetadata(value: string | null): Record<string, unknown> | undefined {
256
+ if (!value) return undefined
257
+ const parsed = JSON.parse(value) as unknown
258
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
259
+ ? (parsed as Record<string, unknown>)
260
+ : undefined
261
+ }
262
+
263
+ function rowToLinkedIdentity(row: LinkedIdentityRow): LinkedIdentity {
264
+ return {
265
+ id: row.id,
266
+ userId: row.user_id,
267
+ provider: row.provider,
268
+ providerUserId: row.provider_user_id,
269
+ email: row.email,
270
+ linkedAt: row.linked_at,
271
+ }
272
+ }
@@ -0,0 +1,323 @@
1
+ import type { OrgStore } from '../org/org-store'
2
+ import type {
3
+ CollectionScopeResolver,
4
+ Permission,
5
+ RbacConfig,
6
+ RoleDefinition,
7
+ ScopeContext,
8
+ ScopeFilter,
9
+ SyncScopes,
10
+ } from './rbac-types'
11
+ import {
12
+ BUILT_IN_ROLES,
13
+ CircularInheritanceError,
14
+ RoleNotFoundError,
15
+ permissionCovers,
16
+ } from './rbac-types'
17
+
18
+ // ============================================================================
19
+ // RbacEngine
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Permission evaluation engine for role-based access control.
24
+ *
25
+ * The engine resolves permissions through role inheritance, supports
26
+ * wildcard matching, and integrates with the OrgStore for membership lookups.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const rbac = new RbacEngine({ orgStore })
31
+ *
32
+ * // Check a permission
33
+ * const canWrite = await rbac.hasPermission('user-1', 'org-1', 'todos:write')
34
+ *
35
+ * // Get all permissions for a user in an org
36
+ * const perms = await rbac.getUserPermissions('user-1', 'org-1')
37
+ *
38
+ * // Resolve sync scopes
39
+ * const scopes = await rbac.resolveScopes('user-1', 'org-1')
40
+ * ```
41
+ */
42
+ export class RbacEngine {
43
+ private readonly orgStore: OrgStore
44
+ private readonly roleMap: Map<string, RoleDefinition>
45
+ private readonly resolvedPermissions = new Map<string, Permission[]>()
46
+ private readonly collectionResolvers = new Map<string, CollectionScopeResolver>()
47
+
48
+ constructor(orgStore: OrgStore, config?: RbacConfig) {
49
+ this.orgStore = orgStore
50
+
51
+ const roles = config?.roles ?? [...BUILT_IN_ROLES]
52
+ this.roleMap = new Map()
53
+ for (const role of roles) {
54
+ this.roleMap.set(role.name, role)
55
+ }
56
+
57
+ // Validate and pre-resolve all role permissions
58
+ this.validateRoles()
59
+ }
60
+
61
+ // --- Permission Checks ---
62
+
63
+ /**
64
+ * Check if a user has a specific permission in an organization.
65
+ *
66
+ * Resolves the user's role from the org membership, then evaluates
67
+ * the role's permissions (including inherited ones) against the required permission.
68
+ *
69
+ * Returns false (not an error) if the user is not a member.
70
+ */
71
+ async hasPermission(userId: string, orgId: string, permission: Permission): Promise<boolean> {
72
+ const membership = await this.orgStore.getMembership(orgId, userId)
73
+ if (!membership) return false
74
+
75
+ const rolePerms = this.getRolePermissions(membership.role)
76
+ return rolePerms.some((granted) => permissionCovers(granted, permission))
77
+ }
78
+
79
+ /**
80
+ * Get all effective permissions for a user in an organization.
81
+ *
82
+ * Returns an empty array if the user is not a member.
83
+ */
84
+ async getUserPermissions(userId: string, orgId: string): Promise<Permission[]> {
85
+ const membership = await this.orgStore.getMembership(orgId, userId)
86
+ if (!membership) return []
87
+
88
+ return this.getRolePermissions(membership.role)
89
+ }
90
+
91
+ /**
92
+ * Get all effective permissions for a role name.
93
+ * Includes permissions from inherited roles.
94
+ *
95
+ * @throws {RoleNotFoundError} if the role is not defined
96
+ */
97
+ getRolePermissions(roleName: string): Permission[] {
98
+ const cached = this.resolvedPermissions.get(roleName)
99
+ if (cached) return cached
100
+
101
+ if (!this.roleMap.has(roleName)) {
102
+ throw new RoleNotFoundError(roleName)
103
+ }
104
+
105
+ const perms = this.resolvePermissionsForRole(roleName, new Set())
106
+ this.resolvedPermissions.set(roleName, perms)
107
+ return perms
108
+ }
109
+
110
+ /**
111
+ * Check if a role has a specific permission.
112
+ */
113
+ roleHasPermission(roleName: string, permission: Permission): boolean {
114
+ const perms = this.getRolePermissions(roleName)
115
+ return perms.some((granted) => permissionCovers(granted, permission))
116
+ }
117
+
118
+ // --- Scope Resolution ---
119
+
120
+ /**
121
+ * Register a custom scope resolver for a collection.
122
+ *
123
+ * Custom resolvers override the default scope logic for that collection.
124
+ */
125
+ registerScopeResolver(collection: string, resolver: CollectionScopeResolver): void {
126
+ this.collectionResolvers.set(collection, resolver)
127
+ }
128
+
129
+ /**
130
+ * Resolve sync scopes for a user in an organization.
131
+ *
132
+ * The scopes determine what data the user can see and modify during sync.
133
+ * - Owner/Admin: all org data
134
+ * - Member: all org data (read + write)
135
+ * - Viewer: all org data (read-only)
136
+ * - Billing: no data scopes (billing-only access)
137
+ *
138
+ * Custom collection resolvers can override the defaults.
139
+ *
140
+ * @param collections - List of collection names to resolve scopes for.
141
+ * If not provided, only custom-registered collections are included.
142
+ */
143
+ async resolveScopes(
144
+ userId: string,
145
+ orgId: string,
146
+ collections?: string[],
147
+ ): Promise<SyncScopes | null> {
148
+ const membership = await this.orgStore.getMembership(orgId, userId)
149
+ if (!membership) return null
150
+
151
+ const permissions = this.getRolePermissions(membership.role)
152
+ const ctx: ScopeContext = {
153
+ userId,
154
+ orgId,
155
+ role: membership.role,
156
+ permissions,
157
+ }
158
+
159
+ const scopes: SyncScopes = {}
160
+
161
+ // Check if user has any data permissions at all
162
+ const hasAnyRead = permissions.some((p) => permissionCovers(p, '*:read' as Permission))
163
+ if (!hasAnyRead) {
164
+ // No data access (e.g., billing-only role)
165
+ return scopes
166
+ }
167
+
168
+ const isReadOnly = !permissions.some((p) => permissionCovers(p, '*:write' as Permission))
169
+
170
+ const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()]
171
+
172
+ for (const collection of collectionsToResolve) {
173
+ // Check custom resolver first
174
+ const customResolver = this.collectionResolvers.get(collection)
175
+ if (customResolver) {
176
+ const customScope = customResolver(ctx)
177
+ if (customScope) {
178
+ scopes[collection] = customScope
179
+ }
180
+ continue
181
+ }
182
+
183
+ // Default scope: filter by orgId
184
+ const scope: ScopeFilter = { orgId }
185
+ if (isReadOnly) {
186
+ scope.__readonly = true
187
+ }
188
+ scopes[collection] = scope
189
+ }
190
+
191
+ return scopes
192
+ }
193
+
194
+ // --- Role Management ---
195
+
196
+ /**
197
+ * Get all defined role names.
198
+ */
199
+ getRoleNames(): string[] {
200
+ return [...this.roleMap.keys()]
201
+ }
202
+
203
+ /**
204
+ * Get a role definition by name.
205
+ */
206
+ getRoleDefinition(roleName: string): RoleDefinition | null {
207
+ return this.roleMap.get(roleName) ?? null
208
+ }
209
+
210
+ // --- Private ---
211
+
212
+ /**
213
+ * Validate all role definitions for circular inheritance and unknown references.
214
+ */
215
+ private validateRoles(): void {
216
+ for (const role of this.roleMap.values()) {
217
+ if (role.inherits) {
218
+ for (const parent of role.inherits) {
219
+ if (!this.roleMap.has(parent)) {
220
+ throw new RoleNotFoundError(parent)
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ // Check for circular inheritance
227
+ for (const role of this.roleMap.values()) {
228
+ this.detectCircularInheritance(role.name, new Set())
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Detect circular inheritance in role hierarchy.
234
+ */
235
+ private detectCircularInheritance(roleName: string, visited: Set<string>): void {
236
+ if (visited.has(roleName)) {
237
+ throw new CircularInheritanceError([...visited, roleName])
238
+ }
239
+ visited.add(roleName)
240
+
241
+ const role = this.roleMap.get(roleName)
242
+ if (role?.inherits) {
243
+ for (const parent of role.inherits) {
244
+ this.detectCircularInheritance(parent, new Set(visited))
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Recursively resolve all permissions for a role, including inherited permissions.
251
+ */
252
+ private resolvePermissionsForRole(roleName: string, visited: Set<string>): Permission[] {
253
+ if (visited.has(roleName)) return []
254
+ visited.add(roleName)
255
+
256
+ const role = this.roleMap.get(roleName)
257
+ if (!role) return []
258
+
259
+ const perms = new Set<Permission>(role.permissions)
260
+
261
+ if (role.inherits) {
262
+ for (const parent of role.inherits) {
263
+ const parentPerms = this.resolvePermissionsForRole(parent, visited)
264
+ for (const p of parentPerms) {
265
+ perms.add(p)
266
+ }
267
+ }
268
+ }
269
+
270
+ return [...perms]
271
+ }
272
+ }
273
+
274
+ // ============================================================================
275
+ // defineRoles builder
276
+ // ============================================================================
277
+
278
+ /**
279
+ * Builder for defining custom roles.
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const roles = defineRoles()
284
+ * .role('viewer', ['*:read'])
285
+ * .role('editor', ['*:write'], { inherits: ['viewer'] })
286
+ * .role('admin', ['org:manage-members'], { inherits: ['editor'] })
287
+ * .build()
288
+ * ```
289
+ */
290
+ export function defineRoles(): RoleBuilder {
291
+ return new RoleBuilder()
292
+ }
293
+
294
+ class RoleBuilder {
295
+ private roles: RoleDefinition[] = []
296
+
297
+ /**
298
+ * Add a role definition.
299
+ */
300
+ role(name: string, permissions: Permission[], options?: { inherits?: string[] }): RoleBuilder {
301
+ this.roles.push({
302
+ name,
303
+ permissions,
304
+ inherits: options?.inherits,
305
+ })
306
+ return this
307
+ }
308
+
309
+ /**
310
+ * Include the built-in roles as a base.
311
+ */
312
+ withBuiltInRoles(): RoleBuilder {
313
+ this.roles = [...BUILT_IN_ROLES, ...this.roles]
314
+ return this
315
+ }
316
+
317
+ /**
318
+ * Build and return the role definitions array.
319
+ */
320
+ build(): RoleDefinition[] {
321
+ return [...this.roles]
322
+ }
323
+ }