@korajs/auth 1.0.0-beta.11 → 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.
- 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 +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- 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 +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -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 +272 -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 +334 -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,758 @@
|
|
|
1
|
+
import type { OrgStore } from './org-store'
|
|
2
|
+
import type {
|
|
3
|
+
CreateOrgParams,
|
|
4
|
+
Membership,
|
|
5
|
+
OrgInvitation,
|
|
6
|
+
OrgRole,
|
|
7
|
+
Organization,
|
|
8
|
+
UpdateOrgParams,
|
|
9
|
+
} from './org-types'
|
|
10
|
+
import {
|
|
11
|
+
CannotRemoveOwnerError,
|
|
12
|
+
InsufficientRoleError,
|
|
13
|
+
InvitationExpiredError,
|
|
14
|
+
InvitationNotFoundError,
|
|
15
|
+
MemberAlreadyExistsError,
|
|
16
|
+
MembershipNotFoundError,
|
|
17
|
+
OrgNotFoundError,
|
|
18
|
+
OrgSlugTakenError,
|
|
19
|
+
hasRoleLevel,
|
|
20
|
+
} from './org-types'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Response envelope returned by all org route handlers.
|
|
28
|
+
* Mirrors AuthRouteResponse for consistency.
|
|
29
|
+
*/
|
|
30
|
+
export interface OrgRouteResponse<T> {
|
|
31
|
+
/** HTTP status code */
|
|
32
|
+
status: number
|
|
33
|
+
/** Either the success payload or an error message */
|
|
34
|
+
body: { data: T } | { error: string }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Configuration for the org route handlers.
|
|
39
|
+
*/
|
|
40
|
+
export interface OrgRoutesConfig {
|
|
41
|
+
/** The organization store backing all org operations */
|
|
42
|
+
orgStore: OrgStore
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a user's email and its verification status (a `UserStore`
|
|
45
|
+
* satisfies this). Invitations are listed and accepted only for the caller's
|
|
46
|
+
* own VERIFIED email; without a lookup, pass the verified identity to
|
|
47
|
+
* `acceptInvitation` / `listMyInvitations` explicitly.
|
|
48
|
+
*/
|
|
49
|
+
userLookup?: {
|
|
50
|
+
findById(userId: string): Promise<{ email: string; emailVerified: boolean } | null>
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A server-verified email identity used to match invitations. */
|
|
55
|
+
export interface VerifiedEmailIdentity {
|
|
56
|
+
email: string
|
|
57
|
+
emailVerified: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Invitation as shown to its invitee: the secret token is never included. */
|
|
61
|
+
export type InviteeInvitation = Omit<OrgInvitation, 'token'>
|
|
62
|
+
|
|
63
|
+
function normalizeEmail(email: string): string {
|
|
64
|
+
return email.trim().toLowerCase()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Maximum length for org name */
|
|
68
|
+
const MAX_ORG_NAME_LENGTH = 200
|
|
69
|
+
|
|
70
|
+
/** Maximum length for org slug */
|
|
71
|
+
const MAX_SLUG_LENGTH = 100
|
|
72
|
+
|
|
73
|
+
/** Slug format: lowercase alphanumeric and hyphens, 2-100 chars */
|
|
74
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Simple email format validation (same logic as auth-routes).
|
|
78
|
+
*/
|
|
79
|
+
function isValidEmail(email: string): boolean {
|
|
80
|
+
// See the matching guard in auth-routes.ts: request bodies are untyped at
|
|
81
|
+
// runtime, so a missing `email` field must not crash this check.
|
|
82
|
+
if (typeof email !== 'string' || email.length === 0 || email.length > 254) return false
|
|
83
|
+
const atIndex = email.indexOf('@')
|
|
84
|
+
if (atIndex < 1) return false
|
|
85
|
+
const domain = email.slice(atIndex + 1)
|
|
86
|
+
if (domain.length === 0 || !domain.includes('.')) return false
|
|
87
|
+
if (email.indexOf('@', atIndex + 1) !== -1) return false
|
|
88
|
+
if (email.includes(' ')) return false
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Strip control characters from a string.
|
|
94
|
+
*/
|
|
95
|
+
function sanitize(value: string): string {
|
|
96
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitization
|
|
97
|
+
return value.replace(/[\x00-\x1f\x7f]/g, '').trim()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// OrgRoutes
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Server-side route handlers for organization management.
|
|
106
|
+
*
|
|
107
|
+
* These handlers enforce authorization (role checks), input validation,
|
|
108
|
+
* and produce transport-agnostic response objects. Wire them to your
|
|
109
|
+
* HTTP framework (Express, Hono, Fastify, etc.):
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* const orgRoutes = new OrgRoutes({ orgStore: new InMemoryOrgStore() })
|
|
114
|
+
*
|
|
115
|
+
* app.post('/orgs', async (req, res) => {
|
|
116
|
+
* const result = await orgRoutes.createOrg(req.userId, req.body)
|
|
117
|
+
* res.status(result.status).json(result.body)
|
|
118
|
+
* })
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export class OrgRoutes {
|
|
122
|
+
private readonly store: OrgStore
|
|
123
|
+
|
|
124
|
+
private readonly userLookup: OrgRoutesConfig['userLookup']
|
|
125
|
+
|
|
126
|
+
constructor(config: OrgRoutesConfig) {
|
|
127
|
+
this.store = config.orgStore
|
|
128
|
+
this.userLookup = config.userLookup
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Organizations ---
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a new organization. The authenticated user becomes the owner.
|
|
135
|
+
*/
|
|
136
|
+
async createOrg(
|
|
137
|
+
userId: string,
|
|
138
|
+
params: { name?: unknown; slug?: unknown; metadata?: unknown },
|
|
139
|
+
): Promise<OrgRouteResponse<Organization>> {
|
|
140
|
+
// Validate name
|
|
141
|
+
if (typeof params.name !== 'string' || params.name.trim().length === 0) {
|
|
142
|
+
return { status: 400, body: { error: 'Organization name is required.' } }
|
|
143
|
+
}
|
|
144
|
+
const name = sanitize(params.name)
|
|
145
|
+
if (name.length > MAX_ORG_NAME_LENGTH) {
|
|
146
|
+
return {
|
|
147
|
+
status: 400,
|
|
148
|
+
body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` },
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate slug (optional)
|
|
153
|
+
let slug: string | undefined
|
|
154
|
+
if (params.slug !== undefined) {
|
|
155
|
+
if (typeof params.slug !== 'string') {
|
|
156
|
+
return { status: 400, body: { error: 'Slug must be a string.' } }
|
|
157
|
+
}
|
|
158
|
+
slug = params.slug.toLowerCase().trim()
|
|
159
|
+
if (slug.length < 2) {
|
|
160
|
+
return { status: 400, body: { error: 'Slug must be at least 2 characters.' } }
|
|
161
|
+
}
|
|
162
|
+
if (slug.length > MAX_SLUG_LENGTH) {
|
|
163
|
+
return {
|
|
164
|
+
status: 400,
|
|
165
|
+
body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` },
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
169
|
+
return {
|
|
170
|
+
status: 400,
|
|
171
|
+
body: {
|
|
172
|
+
error:
|
|
173
|
+
'Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.',
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Validate metadata (optional)
|
|
180
|
+
if (
|
|
181
|
+
params.metadata !== undefined &&
|
|
182
|
+
(typeof params.metadata !== 'object' ||
|
|
183
|
+
params.metadata === null ||
|
|
184
|
+
Array.isArray(params.metadata))
|
|
185
|
+
) {
|
|
186
|
+
return { status: 400, body: { error: 'Metadata must be a plain object.' } }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const createParams: CreateOrgParams = {
|
|
191
|
+
name,
|
|
192
|
+
slug,
|
|
193
|
+
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
194
|
+
}
|
|
195
|
+
const org = await this.store.createOrg(userId, createParams)
|
|
196
|
+
return { status: 201, body: { data: org } }
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof OrgSlugTakenError) {
|
|
199
|
+
return { status: 409, body: { error: err.message } }
|
|
200
|
+
}
|
|
201
|
+
throw err
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get an organization by ID. Requires membership.
|
|
207
|
+
*/
|
|
208
|
+
async getOrg(userId: string, orgId: string): Promise<OrgRouteResponse<Organization>> {
|
|
209
|
+
const membership = await this.store.getMembership(orgId, userId)
|
|
210
|
+
if (!membership) {
|
|
211
|
+
return { status: 404, body: { error: 'Organization not found.' } }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const org = await this.store.getOrg(orgId)
|
|
215
|
+
if (!org) {
|
|
216
|
+
return { status: 404, body: { error: 'Organization not found.' } }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { status: 200, body: { data: org } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Update an organization. Requires admin or higher.
|
|
224
|
+
*/
|
|
225
|
+
async updateOrg(
|
|
226
|
+
userId: string,
|
|
227
|
+
orgId: string,
|
|
228
|
+
params: { name?: unknown; slug?: unknown; metadata?: unknown },
|
|
229
|
+
): Promise<OrgRouteResponse<Organization>> {
|
|
230
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
231
|
+
if (authResult) return authResult
|
|
232
|
+
|
|
233
|
+
const updateParams: UpdateOrgParams = {}
|
|
234
|
+
|
|
235
|
+
// Validate name
|
|
236
|
+
if (params.name !== undefined) {
|
|
237
|
+
if (typeof params.name !== 'string' || params.name.trim().length === 0) {
|
|
238
|
+
return { status: 400, body: { error: 'Organization name must be a non-empty string.' } }
|
|
239
|
+
}
|
|
240
|
+
updateParams.name = sanitize(params.name as string)
|
|
241
|
+
if (updateParams.name.length > MAX_ORG_NAME_LENGTH) {
|
|
242
|
+
return {
|
|
243
|
+
status: 400,
|
|
244
|
+
body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` },
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Validate slug
|
|
250
|
+
if (params.slug !== undefined) {
|
|
251
|
+
if (typeof params.slug !== 'string') {
|
|
252
|
+
return { status: 400, body: { error: 'Slug must be a string.' } }
|
|
253
|
+
}
|
|
254
|
+
updateParams.slug = (params.slug as string).toLowerCase().trim()
|
|
255
|
+
if (updateParams.slug.length < 2) {
|
|
256
|
+
return { status: 400, body: { error: 'Slug must be at least 2 characters.' } }
|
|
257
|
+
}
|
|
258
|
+
if (updateParams.slug.length > MAX_SLUG_LENGTH) {
|
|
259
|
+
return {
|
|
260
|
+
status: 400,
|
|
261
|
+
body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` },
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!SLUG_PATTERN.test(updateParams.slug)) {
|
|
265
|
+
return {
|
|
266
|
+
status: 400,
|
|
267
|
+
body: {
|
|
268
|
+
error:
|
|
269
|
+
'Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.',
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Validate metadata
|
|
276
|
+
if (params.metadata !== undefined) {
|
|
277
|
+
if (
|
|
278
|
+
typeof params.metadata !== 'object' ||
|
|
279
|
+
params.metadata === null ||
|
|
280
|
+
Array.isArray(params.metadata)
|
|
281
|
+
) {
|
|
282
|
+
return { status: 400, body: { error: 'Metadata must be a plain object.' } }
|
|
283
|
+
}
|
|
284
|
+
updateParams.metadata = params.metadata as Record<string, unknown>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const org = await this.store.updateOrg(orgId, updateParams)
|
|
289
|
+
return { status: 200, body: { data: org } }
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (err instanceof OrgNotFoundError) {
|
|
292
|
+
return { status: 404, body: { error: err.message } }
|
|
293
|
+
}
|
|
294
|
+
if (err instanceof OrgSlugTakenError) {
|
|
295
|
+
return { status: 409, body: { error: err.message } }
|
|
296
|
+
}
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Delete an organization. Requires owner.
|
|
303
|
+
*/
|
|
304
|
+
async deleteOrg(userId: string, orgId: string): Promise<OrgRouteResponse<{ deleted: true }>> {
|
|
305
|
+
const authResult = await this.requireRole(orgId, userId, 'owner')
|
|
306
|
+
if (authResult) return authResult
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await this.store.deleteOrg(orgId)
|
|
310
|
+
return { status: 200, body: { data: { deleted: true } } }
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err instanceof OrgNotFoundError) {
|
|
313
|
+
return { status: 404, body: { error: err.message } }
|
|
314
|
+
}
|
|
315
|
+
throw err
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* List all organizations the authenticated user belongs to.
|
|
321
|
+
*/
|
|
322
|
+
async listUserOrgs(userId: string): Promise<OrgRouteResponse<Organization[]>> {
|
|
323
|
+
const orgs = await this.store.listUserOrgs(userId)
|
|
324
|
+
return { status: 200, body: { data: orgs } }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Members ---
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Add a member to an organization. Requires admin or higher.
|
|
331
|
+
*/
|
|
332
|
+
async addMember(
|
|
333
|
+
userId: string,
|
|
334
|
+
orgId: string,
|
|
335
|
+
params: { targetUserId?: unknown; role?: unknown },
|
|
336
|
+
): Promise<OrgRouteResponse<Membership>> {
|
|
337
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
338
|
+
if (authResult) return authResult
|
|
339
|
+
|
|
340
|
+
if (typeof params.targetUserId !== 'string' || params.targetUserId.length === 0) {
|
|
341
|
+
return { status: 400, body: { error: 'Target user ID is required.' } }
|
|
342
|
+
}
|
|
343
|
+
if (typeof params.role !== 'string' || !isValidRole(params.role)) {
|
|
344
|
+
return {
|
|
345
|
+
status: 400,
|
|
346
|
+
body: { error: 'A valid role is required (admin, member, viewer, billing).' },
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cannot assign owner role via addMember — use transferOwnership
|
|
351
|
+
if (params.role === 'owner') {
|
|
352
|
+
return {
|
|
353
|
+
status: 400,
|
|
354
|
+
body: { error: 'Cannot assign owner role directly. Use ownership transfer.' },
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Admins cannot add other admins (only owner can)
|
|
359
|
+
const callerMembership = await this.store.getMembership(orgId, userId)
|
|
360
|
+
if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
|
|
361
|
+
return { status: 403, body: { error: 'Only the owner can add admin members.' } }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const membership = await this.store.addMember(
|
|
366
|
+
orgId,
|
|
367
|
+
params.targetUserId,
|
|
368
|
+
params.role as OrgRole,
|
|
369
|
+
userId,
|
|
370
|
+
)
|
|
371
|
+
return { status: 201, body: { data: membership } }
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof OrgNotFoundError) {
|
|
374
|
+
return { status: 404, body: { error: err.message } }
|
|
375
|
+
}
|
|
376
|
+
if (err instanceof MemberAlreadyExistsError) {
|
|
377
|
+
return { status: 409, body: { error: err.message } }
|
|
378
|
+
}
|
|
379
|
+
throw err
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Remove a member from an organization. Requires admin or higher.
|
|
385
|
+
* Members can also remove themselves (leave).
|
|
386
|
+
*/
|
|
387
|
+
async removeMember(
|
|
388
|
+
userId: string,
|
|
389
|
+
orgId: string,
|
|
390
|
+
targetUserId: string,
|
|
391
|
+
): Promise<OrgRouteResponse<{ removed: true }>> {
|
|
392
|
+
// Allow self-removal (leaving) for any member
|
|
393
|
+
const isSelfRemoval = userId === targetUserId
|
|
394
|
+
|
|
395
|
+
if (!isSelfRemoval) {
|
|
396
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
397
|
+
if (authResult) return authResult
|
|
398
|
+
} else {
|
|
399
|
+
// Verify caller is a member
|
|
400
|
+
const membership = await this.store.getMembership(orgId, userId)
|
|
401
|
+
if (!membership) {
|
|
402
|
+
return { status: 404, body: { error: 'Organization not found.' } }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await this.store.removeMember(orgId, targetUserId)
|
|
408
|
+
return { status: 200, body: { data: { removed: true } } }
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err instanceof OrgNotFoundError) {
|
|
411
|
+
return { status: 404, body: { error: err.message } }
|
|
412
|
+
}
|
|
413
|
+
if (err instanceof CannotRemoveOwnerError) {
|
|
414
|
+
return { status: 400, body: { error: err.message } }
|
|
415
|
+
}
|
|
416
|
+
if (err instanceof MembershipNotFoundError) {
|
|
417
|
+
return { status: 404, body: { error: err.message } }
|
|
418
|
+
}
|
|
419
|
+
throw err
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Update a member's role. Requires admin or higher.
|
|
425
|
+
*/
|
|
426
|
+
async updateMemberRole(
|
|
427
|
+
userId: string,
|
|
428
|
+
orgId: string,
|
|
429
|
+
params: { targetUserId?: unknown; role?: unknown },
|
|
430
|
+
): Promise<OrgRouteResponse<Membership>> {
|
|
431
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
432
|
+
if (authResult) return authResult
|
|
433
|
+
|
|
434
|
+
if (typeof params.targetUserId !== 'string' || params.targetUserId.length === 0) {
|
|
435
|
+
return { status: 400, body: { error: 'Target user ID is required.' } }
|
|
436
|
+
}
|
|
437
|
+
if (typeof params.role !== 'string' || !isValidRole(params.role)) {
|
|
438
|
+
return {
|
|
439
|
+
status: 400,
|
|
440
|
+
body: { error: 'A valid role is required (admin, member, viewer, billing).' },
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (params.role === 'owner') {
|
|
444
|
+
return {
|
|
445
|
+
status: 400,
|
|
446
|
+
body: { error: 'Cannot assign owner role directly. Use ownership transfer.' },
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The owner's role cannot be changed through this endpoint. Without this
|
|
451
|
+
// guard an admin could demote the owner (e.g. to "viewer"), stripping the
|
|
452
|
+
// owner of owner-gated powers while org.ownerId still points at them —
|
|
453
|
+
// locking the account out of deleteOrg / transferOwnership. Ownership
|
|
454
|
+
// changes must go through transferOwnership.
|
|
455
|
+
const org = await this.store.getOrg(orgId)
|
|
456
|
+
if (org && org.ownerId === params.targetUserId) {
|
|
457
|
+
return {
|
|
458
|
+
status: 403,
|
|
459
|
+
body: { error: "The organization owner's role cannot be changed. Use ownership transfer." },
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Admins cannot promote to admin (only owner can)
|
|
464
|
+
const callerMembership = await this.store.getMembership(orgId, userId)
|
|
465
|
+
if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
|
|
466
|
+
return { status: 403, body: { error: 'Only the owner can assign admin role.' } }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const membership = await this.store.updateMemberRole(
|
|
471
|
+
orgId,
|
|
472
|
+
params.targetUserId,
|
|
473
|
+
params.role as OrgRole,
|
|
474
|
+
)
|
|
475
|
+
return { status: 200, body: { data: membership } }
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (err instanceof MembershipNotFoundError) {
|
|
478
|
+
return { status: 404, body: { error: err.message } }
|
|
479
|
+
}
|
|
480
|
+
throw err
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* List all members of an organization. Requires membership.
|
|
486
|
+
*/
|
|
487
|
+
async listMembers(userId: string, orgId: string): Promise<OrgRouteResponse<Membership[]>> {
|
|
488
|
+
const membership = await this.store.getMembership(orgId, userId)
|
|
489
|
+
if (!membership) {
|
|
490
|
+
return { status: 404, body: { error: 'Organization not found.' } }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const members = await this.store.listMembers(orgId)
|
|
495
|
+
return { status: 200, body: { data: members } }
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (err instanceof OrgNotFoundError) {
|
|
498
|
+
return { status: 404, body: { error: err.message } }
|
|
499
|
+
}
|
|
500
|
+
throw err
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Transfer ownership to another member. Requires owner.
|
|
506
|
+
*/
|
|
507
|
+
async transferOwnership(
|
|
508
|
+
userId: string,
|
|
509
|
+
orgId: string,
|
|
510
|
+
params: { newOwnerId?: unknown },
|
|
511
|
+
): Promise<OrgRouteResponse<{ transferred: true }>> {
|
|
512
|
+
const authResult = await this.requireRole(orgId, userId, 'owner')
|
|
513
|
+
if (authResult) return authResult
|
|
514
|
+
|
|
515
|
+
if (typeof params.newOwnerId !== 'string' || params.newOwnerId.length === 0) {
|
|
516
|
+
return { status: 400, body: { error: 'New owner ID is required.' } }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (params.newOwnerId === userId) {
|
|
520
|
+
return { status: 400, body: { error: 'You are already the owner.' } }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await this.store.transferOwnership(orgId, params.newOwnerId)
|
|
525
|
+
return { status: 200, body: { data: { transferred: true } } }
|
|
526
|
+
} catch (err) {
|
|
527
|
+
if (err instanceof OrgNotFoundError) {
|
|
528
|
+
return { status: 404, body: { error: err.message } }
|
|
529
|
+
}
|
|
530
|
+
if (err instanceof MembershipNotFoundError) {
|
|
531
|
+
return { status: 404, body: { error: 'Target user is not a member of this organization.' } }
|
|
532
|
+
}
|
|
533
|
+
throw err
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// --- Invitations ---
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Create an invitation to join the organization. Requires admin or higher.
|
|
541
|
+
*/
|
|
542
|
+
async createInvitation(
|
|
543
|
+
userId: string,
|
|
544
|
+
orgId: string,
|
|
545
|
+
params: { email?: unknown; role?: unknown },
|
|
546
|
+
): Promise<OrgRouteResponse<OrgInvitation>> {
|
|
547
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
548
|
+
if (authResult) return authResult
|
|
549
|
+
|
|
550
|
+
if (typeof params.email !== 'string' || !isValidEmail(params.email.trim())) {
|
|
551
|
+
return { status: 400, body: { error: 'A valid email address is required.' } }
|
|
552
|
+
}
|
|
553
|
+
if (typeof params.role !== 'string' || !isValidRole(params.role)) {
|
|
554
|
+
return {
|
|
555
|
+
status: 400,
|
|
556
|
+
body: { error: 'A valid role is required (admin, member, viewer, billing).' },
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (params.role === 'owner') {
|
|
560
|
+
return {
|
|
561
|
+
status: 400,
|
|
562
|
+
body: { error: 'Cannot invite with owner role. Use ownership transfer.' },
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Admins cannot invite admins
|
|
567
|
+
const callerMembership = await this.store.getMembership(orgId, userId)
|
|
568
|
+
if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
|
|
569
|
+
return { status: 403, body: { error: 'Only the owner can invite admin members.' } }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const invitation = await this.store.createInvitation(orgId, userId, {
|
|
574
|
+
email: params.email.trim(),
|
|
575
|
+
role: params.role as OrgRole,
|
|
576
|
+
})
|
|
577
|
+
return { status: 201, body: { data: invitation } }
|
|
578
|
+
} catch (err) {
|
|
579
|
+
if (err instanceof OrgNotFoundError) {
|
|
580
|
+
return { status: 404, body: { error: err.message } }
|
|
581
|
+
}
|
|
582
|
+
throw err
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Accept an invitation by its token. The authenticated user joins the org
|
|
588
|
+
* only if the invitation was addressed to the user's own verified email.
|
|
589
|
+
*
|
|
590
|
+
* @param userId - The authenticated user
|
|
591
|
+
* @param params - The invitation token
|
|
592
|
+
* @param identity - The user's verified email identity; resolved through
|
|
593
|
+
* `userLookup` when omitted
|
|
594
|
+
*/
|
|
595
|
+
async acceptInvitation(
|
|
596
|
+
userId: string,
|
|
597
|
+
params: { token?: unknown },
|
|
598
|
+
identity?: VerifiedEmailIdentity,
|
|
599
|
+
): Promise<OrgRouteResponse<Membership>> {
|
|
600
|
+
if (typeof params.token !== 'string' || params.token.length === 0) {
|
|
601
|
+
return { status: 400, body: { error: 'Invitation token is required.' } }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const verified = await this.resolveIdentity(userId, identity)
|
|
605
|
+
if (!verified) {
|
|
606
|
+
return {
|
|
607
|
+
status: 403,
|
|
608
|
+
body: { error: 'A verified email address is required to accept invitations.' },
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
// Check the addressee BEFORE consuming, so a stranger holding a leaked
|
|
614
|
+
// token can neither join nor burn the invitation.
|
|
615
|
+
const pending = await this.store.getInvitationByToken(params.token)
|
|
616
|
+
if (pending && normalizeEmail(pending.email) !== normalizeEmail(verified.email)) {
|
|
617
|
+
return {
|
|
618
|
+
status: 403,
|
|
619
|
+
body: { error: 'This invitation was sent to a different email address.' },
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const invitation = await this.store.consumeInvitation(params.token)
|
|
623
|
+
|
|
624
|
+
// Add the user as a member with the invited role
|
|
625
|
+
const membership = await this.store.addMember(
|
|
626
|
+
invitation.orgId,
|
|
627
|
+
userId,
|
|
628
|
+
invitation.role,
|
|
629
|
+
invitation.invitedBy,
|
|
630
|
+
)
|
|
631
|
+
return { status: 200, body: { data: membership } }
|
|
632
|
+
} catch (err) {
|
|
633
|
+
if (err instanceof InvitationNotFoundError) {
|
|
634
|
+
return { status: 404, body: { error: err.message } }
|
|
635
|
+
}
|
|
636
|
+
if (err instanceof InvitationExpiredError) {
|
|
637
|
+
return { status: 410, body: { error: err.message } }
|
|
638
|
+
}
|
|
639
|
+
if (err instanceof MemberAlreadyExistsError) {
|
|
640
|
+
return { status: 409, body: { error: 'You are already a member of this organization.' } }
|
|
641
|
+
}
|
|
642
|
+
throw err
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Revoke a pending invitation. Requires admin or higher.
|
|
648
|
+
*/
|
|
649
|
+
async revokeInvitation(
|
|
650
|
+
userId: string,
|
|
651
|
+
orgId: string,
|
|
652
|
+
invitationId: string,
|
|
653
|
+
): Promise<OrgRouteResponse<{ revoked: true }>> {
|
|
654
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
655
|
+
if (authResult) return authResult
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
await this.store.revokeInvitation(orgId, invitationId)
|
|
659
|
+
return { status: 200, body: { data: { revoked: true } } }
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if (err instanceof InvitationNotFoundError) {
|
|
662
|
+
return { status: 404, body: { error: err.message } }
|
|
663
|
+
}
|
|
664
|
+
throw err
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* List pending invitations for an organization. Requires admin or higher.
|
|
670
|
+
*/
|
|
671
|
+
async listPendingInvitations(
|
|
672
|
+
userId: string,
|
|
673
|
+
orgId: string,
|
|
674
|
+
): Promise<OrgRouteResponse<OrgInvitation[]>> {
|
|
675
|
+
const authResult = await this.requireRole(orgId, userId, 'admin')
|
|
676
|
+
if (authResult) return authResult
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const invitations = await this.store.listPendingInvitations(orgId)
|
|
680
|
+
return { status: 200, body: { data: invitations } }
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (err instanceof OrgNotFoundError) {
|
|
683
|
+
return { status: 404, body: { error: err.message } }
|
|
684
|
+
}
|
|
685
|
+
throw err
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* List pending invitations addressed to the authenticated user's own
|
|
691
|
+
* verified email. The email is resolved server-side (never taken from the
|
|
692
|
+
* request) and invitation tokens are never returned.
|
|
693
|
+
*
|
|
694
|
+
* @param userId - The authenticated user
|
|
695
|
+
* @param identity - The user's verified identity; resolved through `userLookup` when omitted
|
|
696
|
+
*/
|
|
697
|
+
async listMyInvitations(
|
|
698
|
+
userId: string,
|
|
699
|
+
identity?: VerifiedEmailIdentity,
|
|
700
|
+
): Promise<OrgRouteResponse<InviteeInvitation[]>> {
|
|
701
|
+
const verified = await this.resolveIdentity(userId, identity)
|
|
702
|
+
if (!verified || !isValidEmail(verified.email)) {
|
|
703
|
+
return {
|
|
704
|
+
status: 403,
|
|
705
|
+
body: { error: 'A verified email address is required to list invitations.' },
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const invitations = await this.store.listInvitationsForEmail(normalizeEmail(verified.email))
|
|
710
|
+
return {
|
|
711
|
+
status: 200,
|
|
712
|
+
body: { data: invitations.map(({ token: _token, ...rest }) => rest) },
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private async resolveIdentity(
|
|
717
|
+
userId: string,
|
|
718
|
+
identity: VerifiedEmailIdentity | undefined,
|
|
719
|
+
): Promise<VerifiedEmailIdentity | null> {
|
|
720
|
+
const resolved = identity ?? (this.userLookup ? await this.userLookup.findById(userId) : null)
|
|
721
|
+
if (!resolved || !resolved.emailVerified || typeof resolved.email !== 'string') return null
|
|
722
|
+
return { email: resolved.email, emailVerified: true }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// --- Private helpers ---
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Check if the caller has the required role in the org.
|
|
729
|
+
* Returns an error response if not authorized, or null if authorized.
|
|
730
|
+
*/
|
|
731
|
+
private async requireRole(
|
|
732
|
+
orgId: string,
|
|
733
|
+
userId: string,
|
|
734
|
+
requiredRole: OrgRole,
|
|
735
|
+
): Promise<OrgRouteResponse<never> | null> {
|
|
736
|
+
const membership = await this.store.getMembership(orgId, userId)
|
|
737
|
+
if (!membership) {
|
|
738
|
+
return { status: 404, body: { error: 'Organization not found.' } }
|
|
739
|
+
}
|
|
740
|
+
if (!hasRoleLevel(membership.role, requiredRole)) {
|
|
741
|
+
return {
|
|
742
|
+
status: 403,
|
|
743
|
+
body: { error: `This action requires at least the "${requiredRole}" role.` },
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ============================================================================
|
|
751
|
+
// Helpers
|
|
752
|
+
// ============================================================================
|
|
753
|
+
|
|
754
|
+
const ASSIGNABLE_ROLES = new Set<string>(['admin', 'member', 'viewer', 'billing'])
|
|
755
|
+
|
|
756
|
+
function isValidRole(role: string): boolean {
|
|
757
|
+
return ASSIGNABLE_ROLES.has(role) || role === 'owner'
|
|
758
|
+
}
|