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

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 +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  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 +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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,285 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { DuplicateLinkedIdentityError, type LinkedIdentityStore } from './linked-identity-store'
5
+ import type { LinkedIdentity, OAuthState, OAuthStateStore } from './oauth-types'
6
+
7
+ interface SqliteDatabase {
8
+ pragma(source: string): unknown
9
+ exec(source: string): void
10
+ prepare(source: string): {
11
+ run(...params: unknown[]): { changes?: number } | unknown
12
+ get(...params: unknown[]): unknown
13
+ all(...params: unknown[]): unknown[]
14
+ }
15
+ transaction<T>(fn: () => T): () => T
16
+ }
17
+
18
+ interface OAuthStateRow {
19
+ state: string
20
+ provider: string
21
+ redirect_uri: string
22
+ created_at: number
23
+ expires_at: number
24
+ metadata_json: string | null
25
+ code_verifier: string | null
26
+ }
27
+
28
+ interface LinkedIdentityRow {
29
+ id: string
30
+ user_id: string
31
+ provider: string
32
+ provider_user_id: string
33
+ email: string | null
34
+ linked_at: number
35
+ }
36
+
37
+ export class SqliteOAuthStateStore implements OAuthStateStore {
38
+ private readonly db: SqliteDatabase
39
+
40
+ constructor(db: SqliteDatabase) {
41
+ this.db = db
42
+ this.db.pragma('journal_mode = WAL')
43
+ this.ensureTables()
44
+ }
45
+
46
+ async store(state: OAuthState): Promise<void> {
47
+ this.db
48
+ .prepare(`
49
+ INSERT OR REPLACE INTO auth_oauth_states
50
+ (state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
51
+ VALUES (?, ?, ?, ?, ?, ?, ?)
52
+ `)
53
+ .run(
54
+ state.state,
55
+ state.provider,
56
+ state.redirectUri,
57
+ state.createdAt,
58
+ state.expiresAt,
59
+ state.metadata ? JSON.stringify(state.metadata) : null,
60
+ state.codeVerifier ?? null,
61
+ )
62
+ }
63
+
64
+ async consume(stateValue: string): Promise<OAuthState | null> {
65
+ const consumeInTransaction = this.db.transaction(() => {
66
+ const row = this.db
67
+ .prepare('SELECT * FROM auth_oauth_states WHERE state = ?')
68
+ .get(stateValue) as OAuthStateRow | undefined
69
+
70
+ if (!row) return null
71
+
72
+ this.db.prepare('DELETE FROM auth_oauth_states WHERE state = ?').run(stateValue)
73
+ if (Date.now() > row.expires_at) return null
74
+
75
+ return rowToOAuthState(row)
76
+ })
77
+
78
+ return consumeInTransaction()
79
+ }
80
+
81
+ async cleanExpired(): Promise<number> {
82
+ const result = this.db
83
+ .prepare('DELETE FROM auth_oauth_states WHERE expires_at < ?')
84
+ .run(Date.now()) as { changes?: number }
85
+ return result.changes ?? 0
86
+ }
87
+
88
+ private ensureTables(): void {
89
+ this.db.exec(`
90
+ CREATE TABLE IF NOT EXISTS auth_oauth_states (
91
+ state TEXT PRIMARY KEY,
92
+ provider TEXT NOT NULL,
93
+ redirect_uri TEXT NOT NULL,
94
+ created_at INTEGER NOT NULL,
95
+ expires_at INTEGER NOT NULL,
96
+ metadata_json TEXT,
97
+ code_verifier TEXT
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
101
+ ON auth_oauth_states(expires_at);
102
+ `)
103
+ }
104
+ }
105
+
106
+ export class SqliteLinkedIdentityStore implements LinkedIdentityStore {
107
+ private readonly db: SqliteDatabase
108
+
109
+ constructor(db: SqliteDatabase) {
110
+ this.db = db
111
+ this.db.pragma('journal_mode = WAL')
112
+ this.ensureTables()
113
+ }
114
+
115
+ async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
116
+ const row = this.db
117
+ .prepare(`
118
+ SELECT * FROM auth_linked_identities
119
+ WHERE provider = ? AND provider_user_id = ?
120
+ `)
121
+ .get(provider, providerUserId) as LinkedIdentityRow | undefined
122
+
123
+ return row ? rowToLinkedIdentity(row) : null
124
+ }
125
+
126
+ async findByUser(userId: string): Promise<LinkedIdentity[]> {
127
+ const rows = this.db
128
+ .prepare(`
129
+ SELECT * FROM auth_linked_identities
130
+ WHERE user_id = ?
131
+ ORDER BY linked_at ASC
132
+ `)
133
+ .all(userId) as LinkedIdentityRow[]
134
+
135
+ return rows.map(rowToLinkedIdentity)
136
+ }
137
+
138
+ async create(params: {
139
+ userId: string
140
+ provider: string
141
+ providerUserId: string
142
+ email: string | null
143
+ }): Promise<LinkedIdentity> {
144
+ const identity: LinkedIdentity = {
145
+ id: randomUUID(),
146
+ userId: params.userId,
147
+ provider: params.provider,
148
+ providerUserId: params.providerUserId,
149
+ email: params.email,
150
+ linkedAt: Date.now(),
151
+ }
152
+
153
+ try {
154
+ this.db
155
+ .prepare(`
156
+ INSERT INTO auth_linked_identities
157
+ (id, user_id, provider, provider_user_id, email, linked_at)
158
+ VALUES (?, ?, ?, ?, ?, ?)
159
+ `)
160
+ .run(
161
+ identity.id,
162
+ identity.userId,
163
+ identity.provider,
164
+ identity.providerUserId,
165
+ identity.email,
166
+ identity.linkedAt,
167
+ )
168
+ } catch (error) {
169
+ if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
170
+ throw new DuplicateLinkedIdentityError(params.provider)
171
+ }
172
+ throw error
173
+ }
174
+
175
+ return identity
176
+ }
177
+
178
+ async delete(userId: string, provider: string): Promise<void> {
179
+ this.db
180
+ .prepare('DELETE FROM auth_linked_identities WHERE user_id = ? AND provider = ?')
181
+ .run(userId, provider)
182
+ }
183
+
184
+ private ensureTables(): void {
185
+ this.db.exec(`
186
+ CREATE TABLE IF NOT EXISTS auth_linked_identities (
187
+ id TEXT PRIMARY KEY,
188
+ user_id TEXT NOT NULL,
189
+ provider TEXT NOT NULL,
190
+ provider_user_id TEXT NOT NULL,
191
+ email TEXT,
192
+ linked_at INTEGER NOT NULL,
193
+ UNIQUE(provider, provider_user_id),
194
+ UNIQUE(user_id, provider)
195
+ );
196
+
197
+ CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
198
+ ON auth_linked_identities(user_id);
199
+ `)
200
+ }
201
+ }
202
+
203
+ export async function createSqliteOAuthStateStore(options: {
204
+ filename: string
205
+ }): Promise<SqliteOAuthStateStore> {
206
+ const Database = await loadBetterSqlite3()
207
+ const db = new Database(options.filename)
208
+ return new SqliteOAuthStateStore(db as unknown as SqliteDatabase)
209
+ }
210
+
211
+ export async function createSqliteLinkedIdentityStore(options: {
212
+ filename: string
213
+ }): Promise<SqliteLinkedIdentityStore> {
214
+ const Database = await loadBetterSqlite3()
215
+ const db = new Database(options.filename)
216
+ return new SqliteLinkedIdentityStore(db as unknown as SqliteDatabase)
217
+ }
218
+
219
+ export async function createSqliteOAuthStores(options: { filename: string }): Promise<{
220
+ stateStore: SqliteOAuthStateStore
221
+ linkedIdentityStore: SqliteLinkedIdentityStore
222
+ }> {
223
+ const Database = await loadBetterSqlite3()
224
+ ensureDatabaseDirectory(options.filename)
225
+ const db = new Database(options.filename) as unknown as SqliteDatabase
226
+ return {
227
+ stateStore: new SqliteOAuthStateStore(db),
228
+ linkedIdentityStore: new SqliteLinkedIdentityStore(db),
229
+ }
230
+ }
231
+
232
+ async function loadBetterSqlite3(): Promise<new (filename: string) => unknown> {
233
+ try {
234
+ const { createRequire } = await import('node:module')
235
+ const require = createRequire(import.meta.url)
236
+ return require('better-sqlite3') as new (
237
+ filename: string,
238
+ ) => unknown
239
+ } catch {
240
+ throw new Error(
241
+ 'SQLite OAuth stores require the "better-sqlite3" package. Install it in your project dependencies.',
242
+ )
243
+ }
244
+ }
245
+
246
+ function rowToOAuthState(row: OAuthStateRow): OAuthState {
247
+ return {
248
+ state: row.state,
249
+ provider: row.provider,
250
+ redirectUri: row.redirect_uri,
251
+ createdAt: row.created_at,
252
+ expiresAt: row.expires_at,
253
+ metadata: parseMetadata(row.metadata_json),
254
+ codeVerifier: row.code_verifier ?? undefined,
255
+ }
256
+ }
257
+
258
+ function parseMetadata(value: string | null): Record<string, unknown> | undefined {
259
+ if (!value) return undefined
260
+ const parsed = JSON.parse(value) as unknown
261
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
262
+ ? (parsed as Record<string, unknown>)
263
+ : undefined
264
+ }
265
+
266
+ function rowToLinkedIdentity(row: LinkedIdentityRow): LinkedIdentity {
267
+ return {
268
+ id: row.id,
269
+ userId: row.user_id,
270
+ provider: row.provider,
271
+ providerUserId: row.provider_user_id,
272
+ email: row.email,
273
+ linkedAt: row.linked_at,
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Create the directory a SQLite database file lives in, so a default such as
279
+ * `./.kora/kora-server.db` works on a fresh checkout. In-memory databases and `file:`
280
+ * URIs are left alone.
281
+ */
282
+ function ensureDatabaseDirectory(filename: string): void {
283
+ if (filename === '' || filename === ':memory:' || filename.startsWith('file:')) return
284
+ mkdirSync(dirname(filename), { recursive: true })
285
+ }
@@ -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
+ }