@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.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +338 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateInvitationParams,
|
|
3
|
+
CreateOrgParams,
|
|
4
|
+
InvitationStatus,
|
|
5
|
+
Membership,
|
|
6
|
+
OrgInvitation,
|
|
7
|
+
OrgRole,
|
|
8
|
+
Organization,
|
|
9
|
+
UpdateOrgParams,
|
|
10
|
+
} from './org-types'
|
|
11
|
+
import {
|
|
12
|
+
CannotRemoveOwnerError,
|
|
13
|
+
InvitationExpiredError,
|
|
14
|
+
InvitationNotFoundError,
|
|
15
|
+
MemberAlreadyExistsError,
|
|
16
|
+
MembershipNotFoundError,
|
|
17
|
+
OrgNotFoundError,
|
|
18
|
+
OrgSlugTakenError,
|
|
19
|
+
} from './org-types'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// OrgStore interface
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Persistence interface for organizations, memberships, and invitations.
|
|
27
|
+
*
|
|
28
|
+
* Implement this interface to back organizations with any storage:
|
|
29
|
+
* - `InMemoryOrgStore` for development and testing
|
|
30
|
+
* - PostgreSQL/MySQL via Drizzle for production
|
|
31
|
+
* - SQLite for self-hosted or embedded scenarios
|
|
32
|
+
*
|
|
33
|
+
* All methods are async to support any storage backend.
|
|
34
|
+
*/
|
|
35
|
+
export interface OrgStore {
|
|
36
|
+
// --- Organizations ---
|
|
37
|
+
|
|
38
|
+
/** Create a new organization. The caller becomes the owner. */
|
|
39
|
+
createOrg(ownerId: string, params: CreateOrgParams): Promise<Organization>
|
|
40
|
+
|
|
41
|
+
/** Get an organization by ID. Returns null if not found. */
|
|
42
|
+
getOrg(orgId: string): Promise<Organization | null>
|
|
43
|
+
|
|
44
|
+
/** Get an organization by slug. Returns null if not found. */
|
|
45
|
+
getOrgBySlug(slug: string): Promise<Organization | null>
|
|
46
|
+
|
|
47
|
+
/** Update an organization's mutable fields. */
|
|
48
|
+
updateOrg(orgId: string, params: UpdateOrgParams): Promise<Organization>
|
|
49
|
+
|
|
50
|
+
/** Delete an organization and all its memberships and invitations. */
|
|
51
|
+
deleteOrg(orgId: string): Promise<void>
|
|
52
|
+
|
|
53
|
+
/** List all organizations a user is a member of. */
|
|
54
|
+
listUserOrgs(userId: string): Promise<Organization[]>
|
|
55
|
+
|
|
56
|
+
// --- Memberships ---
|
|
57
|
+
|
|
58
|
+
/** Add a user as a member of an organization. */
|
|
59
|
+
addMember(
|
|
60
|
+
orgId: string,
|
|
61
|
+
userId: string,
|
|
62
|
+
role: OrgRole,
|
|
63
|
+
invitedBy: string | null,
|
|
64
|
+
): Promise<Membership>
|
|
65
|
+
|
|
66
|
+
/** Remove a user from an organization. Cannot remove the owner. */
|
|
67
|
+
removeMember(orgId: string, userId: string): Promise<void>
|
|
68
|
+
|
|
69
|
+
/** Update a member's role within an organization. */
|
|
70
|
+
updateMemberRole(orgId: string, userId: string, role: OrgRole): Promise<Membership>
|
|
71
|
+
|
|
72
|
+
/** List all members of an organization. */
|
|
73
|
+
listMembers(orgId: string): Promise<Membership[]>
|
|
74
|
+
|
|
75
|
+
/** Get a specific user's membership in an organization. Returns null if not a member. */
|
|
76
|
+
getMembership(orgId: string, userId: string): Promise<Membership | null>
|
|
77
|
+
|
|
78
|
+
/** Transfer ownership of an organization to another member. */
|
|
79
|
+
transferOwnership(orgId: string, newOwnerId: string): Promise<void>
|
|
80
|
+
|
|
81
|
+
// --- Invitations ---
|
|
82
|
+
|
|
83
|
+
/** Create an invitation to join an organization. */
|
|
84
|
+
createInvitation(
|
|
85
|
+
orgId: string,
|
|
86
|
+
invitedBy: string,
|
|
87
|
+
params: CreateInvitationParams,
|
|
88
|
+
): Promise<OrgInvitation>
|
|
89
|
+
|
|
90
|
+
/** Look up an invitation by its single-use token. Returns null if not found or already consumed. */
|
|
91
|
+
getInvitationByToken(token: string): Promise<OrgInvitation | null>
|
|
92
|
+
|
|
93
|
+
/** Consume an invitation (mark as accepted). Returns the invitation details. */
|
|
94
|
+
consumeInvitation(token: string): Promise<OrgInvitation>
|
|
95
|
+
|
|
96
|
+
/** Revoke a pending invitation. */
|
|
97
|
+
/**
|
|
98
|
+
* Revoke a pending invitation of `orgId`. Must throw InvitationNotFoundError
|
|
99
|
+
* when the invitation belongs to another org (AUTH-4).
|
|
100
|
+
*/
|
|
101
|
+
revokeInvitation(orgId: string, invitationId: string): Promise<void>
|
|
102
|
+
|
|
103
|
+
/** List pending invitations for an organization. */
|
|
104
|
+
listPendingInvitations(orgId: string): Promise<OrgInvitation[]>
|
|
105
|
+
|
|
106
|
+
/** List pending invitations for a specific email address. */
|
|
107
|
+
listInvitationsForEmail(email: string): Promise<OrgInvitation[]>
|
|
108
|
+
|
|
109
|
+
/** Remove expired invitations. Returns count of removed invitations. */
|
|
110
|
+
cleanExpiredInvitations(): Promise<number>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// InMemoryOrgStore
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* In-memory implementation of OrgStore for development and testing.
|
|
119
|
+
*
|
|
120
|
+
* Data is lost when the process exits. For production, implement OrgStore
|
|
121
|
+
* with a persistent backend (PostgreSQL, MySQL, SQLite via Drizzle).
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* const orgStore = new InMemoryOrgStore()
|
|
126
|
+
* const org = await orgStore.createOrg('user-1', { name: 'Acme Inc', slug: 'acme' })
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export class InMemoryOrgStore implements OrgStore {
|
|
130
|
+
private orgs = new Map<string, Organization>()
|
|
131
|
+
private memberships = new Map<string, Membership>()
|
|
132
|
+
private invitations = new Map<string, OrgInvitation>()
|
|
133
|
+
|
|
134
|
+
// --- Organizations ---
|
|
135
|
+
|
|
136
|
+
async createOrg(ownerId: string, params: CreateOrgParams): Promise<Organization> {
|
|
137
|
+
const slug = params.slug ?? slugify(params.name)
|
|
138
|
+
|
|
139
|
+
// Check slug uniqueness
|
|
140
|
+
for (const org of this.orgs.values()) {
|
|
141
|
+
if (org.slug === slug) {
|
|
142
|
+
throw new OrgSlugTakenError()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
const org: Organization = {
|
|
148
|
+
id: generateId(),
|
|
149
|
+
name: params.name,
|
|
150
|
+
slug,
|
|
151
|
+
ownerId,
|
|
152
|
+
createdAt: now,
|
|
153
|
+
updatedAt: now,
|
|
154
|
+
metadata: params.metadata ?? {},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.orgs.set(org.id, org)
|
|
158
|
+
|
|
159
|
+
// Add the creator as owner
|
|
160
|
+
await this.addMember(org.id, ownerId, 'owner', null)
|
|
161
|
+
|
|
162
|
+
return org
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async getOrg(orgId: string): Promise<Organization | null> {
|
|
166
|
+
return this.orgs.get(orgId) ?? null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getOrgBySlug(slug: string): Promise<Organization | null> {
|
|
170
|
+
for (const org of this.orgs.values()) {
|
|
171
|
+
if (org.slug === slug) return org
|
|
172
|
+
}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async updateOrg(orgId: string, params: UpdateOrgParams): Promise<Organization> {
|
|
177
|
+
const org = this.orgs.get(orgId)
|
|
178
|
+
if (!org) throw new OrgNotFoundError()
|
|
179
|
+
|
|
180
|
+
if (params.slug !== undefined && params.slug !== org.slug) {
|
|
181
|
+
// Check slug uniqueness
|
|
182
|
+
for (const existing of this.orgs.values()) {
|
|
183
|
+
if (existing.slug === params.slug && existing.id !== orgId) {
|
|
184
|
+
throw new OrgSlugTakenError()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const updated: Organization = {
|
|
190
|
+
...org,
|
|
191
|
+
name: params.name ?? org.name,
|
|
192
|
+
slug: params.slug ?? org.slug,
|
|
193
|
+
updatedAt: Date.now(),
|
|
194
|
+
metadata: params.metadata ? { ...org.metadata, ...params.metadata } : org.metadata,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.orgs.set(orgId, updated)
|
|
198
|
+
return updated
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async deleteOrg(orgId: string): Promise<void> {
|
|
202
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
|
|
203
|
+
|
|
204
|
+
// Remove all memberships
|
|
205
|
+
for (const [id, membership] of this.memberships) {
|
|
206
|
+
if (membership.orgId === orgId) {
|
|
207
|
+
this.memberships.delete(id)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Remove all invitations
|
|
212
|
+
for (const [id, invitation] of this.invitations) {
|
|
213
|
+
if (invitation.orgId === orgId) {
|
|
214
|
+
this.invitations.delete(id)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.orgs.delete(orgId)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async listUserOrgs(userId: string): Promise<Organization[]> {
|
|
222
|
+
const orgIds = new Set<string>()
|
|
223
|
+
for (const membership of this.memberships.values()) {
|
|
224
|
+
if (membership.userId === userId) {
|
|
225
|
+
orgIds.add(membership.orgId)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result: Organization[] = []
|
|
230
|
+
for (const orgId of orgIds) {
|
|
231
|
+
const org = this.orgs.get(orgId)
|
|
232
|
+
if (org) result.push(org)
|
|
233
|
+
}
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Memberships ---
|
|
238
|
+
|
|
239
|
+
async addMember(
|
|
240
|
+
orgId: string,
|
|
241
|
+
userId: string,
|
|
242
|
+
role: OrgRole,
|
|
243
|
+
invitedBy: string | null,
|
|
244
|
+
): Promise<Membership> {
|
|
245
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
|
|
246
|
+
|
|
247
|
+
// Check if already a member
|
|
248
|
+
for (const membership of this.memberships.values()) {
|
|
249
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
250
|
+
throw new MemberAlreadyExistsError()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const membership: Membership = {
|
|
255
|
+
id: generateId(),
|
|
256
|
+
orgId,
|
|
257
|
+
userId,
|
|
258
|
+
role,
|
|
259
|
+
invitedBy,
|
|
260
|
+
joinedAt: Date.now(),
|
|
261
|
+
metadata: {},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.memberships.set(membership.id, membership)
|
|
265
|
+
return membership
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async removeMember(orgId: string, userId: string): Promise<void> {
|
|
269
|
+
const org = this.orgs.get(orgId)
|
|
270
|
+
if (!org) throw new OrgNotFoundError()
|
|
271
|
+
|
|
272
|
+
if (org.ownerId === userId) {
|
|
273
|
+
throw new CannotRemoveOwnerError()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let found = false
|
|
277
|
+
for (const [id, membership] of this.memberships) {
|
|
278
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
279
|
+
this.memberships.delete(id)
|
|
280
|
+
found = true
|
|
281
|
+
break
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!found) throw new MembershipNotFoundError()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async updateMemberRole(orgId: string, userId: string, role: OrgRole): Promise<Membership> {
|
|
289
|
+
for (const [id, membership] of this.memberships) {
|
|
290
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
291
|
+
const updated = { ...membership, role }
|
|
292
|
+
this.memberships.set(id, updated)
|
|
293
|
+
|
|
294
|
+
// If promoting to owner, update the org's ownerId and demote previous owner
|
|
295
|
+
if (role === 'owner') {
|
|
296
|
+
const org = this.orgs.get(orgId)
|
|
297
|
+
if (org) {
|
|
298
|
+
// Demote previous owner to admin
|
|
299
|
+
for (const [mId, m] of this.memberships) {
|
|
300
|
+
if (m.orgId === orgId && m.userId === org.ownerId && m.userId !== userId) {
|
|
301
|
+
this.memberships.set(mId, { ...m, role: 'admin' })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.orgs.set(orgId, { ...org, ownerId: userId, updatedAt: Date.now() })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return updated
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
throw new MembershipNotFoundError()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async listMembers(orgId: string): Promise<Membership[]> {
|
|
316
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
|
|
317
|
+
|
|
318
|
+
const result: Membership[] = []
|
|
319
|
+
for (const membership of this.memberships.values()) {
|
|
320
|
+
if (membership.orgId === orgId) {
|
|
321
|
+
result.push(membership)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return result
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async getMembership(orgId: string, userId: string): Promise<Membership | null> {
|
|
328
|
+
for (const membership of this.memberships.values()) {
|
|
329
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
330
|
+
return membership
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async transferOwnership(orgId: string, newOwnerId: string): Promise<void> {
|
|
337
|
+
const org = this.orgs.get(orgId)
|
|
338
|
+
if (!org) throw new OrgNotFoundError()
|
|
339
|
+
|
|
340
|
+
// Verify new owner is a member
|
|
341
|
+
const membership = await this.getMembership(orgId, newOwnerId)
|
|
342
|
+
if (!membership) throw new MembershipNotFoundError()
|
|
343
|
+
|
|
344
|
+
// Demote current owner to admin
|
|
345
|
+
await this.updateMemberRole(orgId, org.ownerId, 'admin')
|
|
346
|
+
|
|
347
|
+
// Promote new owner
|
|
348
|
+
await this.updateMemberRole(orgId, newOwnerId, 'owner')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- Invitations ---
|
|
352
|
+
|
|
353
|
+
async createInvitation(
|
|
354
|
+
orgId: string,
|
|
355
|
+
invitedBy: string,
|
|
356
|
+
params: CreateInvitationParams,
|
|
357
|
+
): Promise<OrgInvitation> {
|
|
358
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
|
|
359
|
+
|
|
360
|
+
const now = Date.now()
|
|
361
|
+
const invitation: OrgInvitation = {
|
|
362
|
+
id: generateId(),
|
|
363
|
+
orgId,
|
|
364
|
+
email: params.email.toLowerCase().trim(),
|
|
365
|
+
role: params.role,
|
|
366
|
+
invitedBy,
|
|
367
|
+
token: generateToken(),
|
|
368
|
+
createdAt: now,
|
|
369
|
+
expiresAt: now + 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
370
|
+
status: 'pending',
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.invitations.set(invitation.id, invitation)
|
|
374
|
+
return invitation
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async getInvitationByToken(token: string): Promise<OrgInvitation | null> {
|
|
378
|
+
for (const invitation of this.invitations.values()) {
|
|
379
|
+
if (invitation.token === token && invitation.status === 'pending') {
|
|
380
|
+
return invitation
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async consumeInvitation(token: string): Promise<OrgInvitation> {
|
|
387
|
+
for (const [id, invitation] of this.invitations) {
|
|
388
|
+
if (invitation.token === token) {
|
|
389
|
+
if (invitation.status !== 'pending') {
|
|
390
|
+
throw new InvitationNotFoundError()
|
|
391
|
+
}
|
|
392
|
+
if (Date.now() > invitation.expiresAt) {
|
|
393
|
+
this.invitations.set(id, { ...invitation, status: 'expired' })
|
|
394
|
+
throw new InvitationExpiredError()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const consumed = { ...invitation, status: 'accepted' as InvitationStatus }
|
|
398
|
+
this.invitations.set(id, consumed)
|
|
399
|
+
return consumed
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
throw new InvitationNotFoundError()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async revokeInvitation(orgId: string, invitationId: string): Promise<void> {
|
|
407
|
+
const invitation = this.invitations.get(invitationId)
|
|
408
|
+
if (!invitation || invitation.orgId !== orgId || invitation.status !== 'pending') {
|
|
409
|
+
throw new InvitationNotFoundError()
|
|
410
|
+
}
|
|
411
|
+
this.invitations.set(invitationId, { ...invitation, status: 'revoked' })
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async listPendingInvitations(orgId: string): Promise<OrgInvitation[]> {
|
|
415
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
|
|
416
|
+
|
|
417
|
+
const result: OrgInvitation[] = []
|
|
418
|
+
const now = Date.now()
|
|
419
|
+
for (const invitation of this.invitations.values()) {
|
|
420
|
+
if (invitation.orgId === orgId && invitation.status === 'pending') {
|
|
421
|
+
if (now > invitation.expiresAt) {
|
|
422
|
+
// Auto-expire
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
result.push(invitation)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return result
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async listInvitationsForEmail(email: string): Promise<OrgInvitation[]> {
|
|
432
|
+
const normalizedEmail = email.toLowerCase().trim()
|
|
433
|
+
const result: OrgInvitation[] = []
|
|
434
|
+
const now = Date.now()
|
|
435
|
+
for (const invitation of this.invitations.values()) {
|
|
436
|
+
if (
|
|
437
|
+
invitation.email === normalizedEmail &&
|
|
438
|
+
invitation.status === 'pending' &&
|
|
439
|
+
now <= invitation.expiresAt
|
|
440
|
+
) {
|
|
441
|
+
result.push(invitation)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return result
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async cleanExpiredInvitations(): Promise<number> {
|
|
448
|
+
let count = 0
|
|
449
|
+
const now = Date.now()
|
|
450
|
+
for (const [id, invitation] of this.invitations) {
|
|
451
|
+
if (invitation.status === 'pending' && now > invitation.expiresAt) {
|
|
452
|
+
this.invitations.set(id, { ...invitation, status: 'expired' })
|
|
453
|
+
count++
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return count
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Internal helpers
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
function generateId(): string {
|
|
465
|
+
return globalThis.crypto.randomUUID()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function generateToken(): string {
|
|
469
|
+
const bytes = new Uint8Array(32)
|
|
470
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
471
|
+
let binary = ''
|
|
472
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
473
|
+
binary += String.fromCharCode(bytes[i] as number)
|
|
474
|
+
}
|
|
475
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Convert a name to a URL-friendly slug.
|
|
480
|
+
* Lowercase, replace spaces/special chars with hyphens, collapse multiple hyphens.
|
|
481
|
+
*/
|
|
482
|
+
function slugify(name: string): string {
|
|
483
|
+
return name
|
|
484
|
+
.toLowerCase()
|
|
485
|
+
.trim()
|
|
486
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
487
|
+
.replace(/[\s]+/g, '-')
|
|
488
|
+
.replace(/-+/g, '-')
|
|
489
|
+
.replace(/^-|-$/g, '')
|
|
490
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Organization
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An organization (workspace/team) that groups users together.
|
|
9
|
+
*
|
|
10
|
+
* Organizations are the fundamental unit of multi-tenancy in Kora.
|
|
11
|
+
* Data is scoped to organizations, and users access data through
|
|
12
|
+
* their organization memberships and roles.
|
|
13
|
+
*/
|
|
14
|
+
export interface Organization {
|
|
15
|
+
/** Unique identifier (UUID v7) */
|
|
16
|
+
id: string
|
|
17
|
+
/** Display name of the organization */
|
|
18
|
+
name: string
|
|
19
|
+
/** URL-friendly identifier (unique, lowercase, alphanumeric + hyphens) */
|
|
20
|
+
slug: string
|
|
21
|
+
/** User ID of the organization owner */
|
|
22
|
+
ownerId: string
|
|
23
|
+
/** When the organization was created (ms since epoch) */
|
|
24
|
+
createdAt: number
|
|
25
|
+
/** When the organization was last updated (ms since epoch) */
|
|
26
|
+
updatedAt: number
|
|
27
|
+
/** Arbitrary metadata (plan, billing info, settings, etc.) */
|
|
28
|
+
metadata: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parameters for creating a new organization.
|
|
33
|
+
*/
|
|
34
|
+
export interface CreateOrgParams {
|
|
35
|
+
/** Display name */
|
|
36
|
+
name: string
|
|
37
|
+
/** URL-friendly slug (auto-generated from name if omitted) */
|
|
38
|
+
slug?: string
|
|
39
|
+
/** Optional metadata to attach */
|
|
40
|
+
metadata?: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parameters for updating an existing organization.
|
|
45
|
+
*/
|
|
46
|
+
export interface UpdateOrgParams {
|
|
47
|
+
/** New display name */
|
|
48
|
+
name?: string
|
|
49
|
+
/** New slug */
|
|
50
|
+
slug?: string
|
|
51
|
+
/** Metadata to merge (shallow merge) */
|
|
52
|
+
metadata?: Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Roles
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Built-in organization roles, ordered by decreasing privilege.
|
|
61
|
+
*
|
|
62
|
+
* - **owner**: Full control, can delete org, transfer ownership, manage billing
|
|
63
|
+
* - **admin**: Manage members and settings, full data access
|
|
64
|
+
* - **member**: Read + write own data, read shared data
|
|
65
|
+
* - **viewer**: Read-only access to shared data
|
|
66
|
+
* - **billing**: Billing management only, no data access
|
|
67
|
+
*/
|
|
68
|
+
export const ORG_ROLES = ['owner', 'admin', 'member', 'viewer', 'billing'] as const
|
|
69
|
+
export type OrgRole = (typeof ORG_ROLES)[number]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Role hierarchy for permission inheritance.
|
|
73
|
+
* Higher number = more privilege.
|
|
74
|
+
*/
|
|
75
|
+
export const ROLE_HIERARCHY: Record<OrgRole, number> = {
|
|
76
|
+
viewer: 10,
|
|
77
|
+
billing: 15,
|
|
78
|
+
member: 20,
|
|
79
|
+
admin: 30,
|
|
80
|
+
owner: 40,
|
|
81
|
+
} as const
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if one role has at least the privilege level of another.
|
|
85
|
+
*
|
|
86
|
+
* @param userRole - The user's current role
|
|
87
|
+
* @param requiredRole - The minimum role required
|
|
88
|
+
* @returns true if userRole >= requiredRole in the hierarchy
|
|
89
|
+
*/
|
|
90
|
+
export function hasRoleLevel(userRole: OrgRole, requiredRole: OrgRole): boolean {
|
|
91
|
+
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Membership
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A user's membership in an organization.
|
|
100
|
+
*/
|
|
101
|
+
export interface Membership {
|
|
102
|
+
/** Unique identifier for this membership record */
|
|
103
|
+
id: string
|
|
104
|
+
/** Organization this membership belongs to */
|
|
105
|
+
orgId: string
|
|
106
|
+
/** User who is a member */
|
|
107
|
+
userId: string
|
|
108
|
+
/** Role within the organization */
|
|
109
|
+
role: OrgRole
|
|
110
|
+
/** User who invited this member (null if founder) */
|
|
111
|
+
invitedBy: string | null
|
|
112
|
+
/** When the user joined the organization (ms since epoch) */
|
|
113
|
+
joinedAt: number
|
|
114
|
+
/** Arbitrary metadata (department, title, etc.) */
|
|
115
|
+
metadata: Record<string, unknown>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Invitations
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Invitation status lifecycle.
|
|
124
|
+
*/
|
|
125
|
+
export const INVITATION_STATUSES = ['pending', 'accepted', 'revoked', 'expired'] as const
|
|
126
|
+
export type InvitationStatus = (typeof INVITATION_STATUSES)[number]
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An invitation to join an organization.
|
|
130
|
+
*
|
|
131
|
+
* Invitations are sent by email and include a single-use token.
|
|
132
|
+
* They expire after a configurable duration (default: 7 days).
|
|
133
|
+
*/
|
|
134
|
+
export interface OrgInvitation {
|
|
135
|
+
/** Unique identifier */
|
|
136
|
+
id: string
|
|
137
|
+
/** Organization the invitation is for */
|
|
138
|
+
orgId: string
|
|
139
|
+
/** Email address of the invitee */
|
|
140
|
+
email: string
|
|
141
|
+
/** Role the invitee will receive upon accepting */
|
|
142
|
+
role: OrgRole
|
|
143
|
+
/** User who created the invitation */
|
|
144
|
+
invitedBy: string
|
|
145
|
+
/** Cryptographically random single-use token */
|
|
146
|
+
token: string
|
|
147
|
+
/** When the invitation was created (ms since epoch) */
|
|
148
|
+
createdAt: number
|
|
149
|
+
/** When the invitation expires (ms since epoch) */
|
|
150
|
+
expiresAt: number
|
|
151
|
+
/** Current status */
|
|
152
|
+
status: InvitationStatus
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parameters for creating an invitation.
|
|
157
|
+
*/
|
|
158
|
+
export interface CreateInvitationParams {
|
|
159
|
+
/** Email address to invite */
|
|
160
|
+
email: string
|
|
161
|
+
/** Role to assign when the invitation is accepted */
|
|
162
|
+
role: OrgRole
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Errors
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Base error for organization-related operations.
|
|
171
|
+
*/
|
|
172
|
+
export class OrgError extends KoraError {
|
|
173
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
174
|
+
super(message, code, context)
|
|
175
|
+
this.name = 'OrgError'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class OrgNotFoundError extends OrgError {
|
|
180
|
+
constructor() {
|
|
181
|
+
super('Organization not found.', 'ORG_NOT_FOUND')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class OrgSlugTakenError extends OrgError {
|
|
186
|
+
constructor() {
|
|
187
|
+
super('An organization with this slug already exists.', 'ORG_SLUG_TAKEN')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class MembershipNotFoundError extends OrgError {
|
|
192
|
+
constructor() {
|
|
193
|
+
super('User is not a member of this organization.', 'MEMBERSHIP_NOT_FOUND')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export class MemberAlreadyExistsError extends OrgError {
|
|
198
|
+
constructor() {
|
|
199
|
+
super('User is already a member of this organization.', 'MEMBER_ALREADY_EXISTS')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export class InsufficientRoleError extends OrgError {
|
|
204
|
+
constructor(required: OrgRole) {
|
|
205
|
+
super(`This action requires at least the "${required}" role.`, 'INSUFFICIENT_ROLE', {
|
|
206
|
+
requiredRole: required,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class CannotRemoveOwnerError extends OrgError {
|
|
212
|
+
constructor() {
|
|
213
|
+
super(
|
|
214
|
+
'The organization owner cannot be removed. Transfer ownership first.',
|
|
215
|
+
'CANNOT_REMOVE_OWNER',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export class InvitationNotFoundError extends OrgError {
|
|
221
|
+
constructor() {
|
|
222
|
+
super('Invitation not found or has already been used.', 'INVITATION_NOT_FOUND')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export class InvitationExpiredError extends OrgError {
|
|
227
|
+
constructor() {
|
|
228
|
+
super('This invitation has expired.', 'INVITATION_EXPIRED')
|
|
229
|
+
}
|
|
230
|
+
}
|