@lunora/auth 1.0.0-alpha.20 → 1.0.0-alpha.22

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/dist/index.d.mts CHANGED
@@ -91,6 +91,35 @@ interface AuthInvitation {
91
91
  role?: null | string;
92
92
  status?: null | string;
93
93
  }
94
+ /** One team row (from the `organization` plugin with `teams.enabled`). */
95
+ interface AuthTeam {
96
+ [key: string]: unknown;
97
+ createdAt?: AuthTimestamp;
98
+ id: string;
99
+ name?: null | string;
100
+ organizationId: string;
101
+ }
102
+ /** One team-membership row (teams). */
103
+ interface AuthTeamMember {
104
+ [key: string]: unknown;
105
+ createdAt?: AuthTimestamp;
106
+ id: string;
107
+ teamId: string;
108
+ userId: string;
109
+ }
110
+ /**
111
+ * One custom organization role (from the organization plugin's dynamic
112
+ * access-control). `permission` is a JSON string of a `resource → actions[]` map
113
+ * as stored; the studio parses it for display/editing.
114
+ */
115
+ interface AuthOrgRole {
116
+ [key: string]: unknown;
117
+ createdAt?: AuthTimestamp;
118
+ id: string;
119
+ organizationId: string;
120
+ permission?: null | string;
121
+ role?: null | string;
122
+ }
94
123
  /** One registered passkey. Credential secrets (`publicKey`) are stripped. */
95
124
  interface AuthPasskey {
96
125
  [key: string]: unknown;
@@ -124,6 +153,63 @@ interface AuthCapabilities {
124
153
  /** The `two-factor` plugin: per-user 2FA status / disable. */
125
154
  twoFactor: boolean;
126
155
  }
156
+ /**
157
+ * One app/plugin-defined field the create-user form should render, derived from
158
+ * the merged better-auth `user` table (core + plugin + `additionalFields`). Only
159
+ * user-settable columns are surfaced — server-managed flags (`input: false`),
160
+ * foreign keys (`references`), and the core columns the form already handles
161
+ * (`email`/`name`/`role`/ban state/…) are filtered out upstream.
162
+ */
163
+ interface AuthUserFieldSpec {
164
+ /** Logical field name (the key passed back in `createUser`'s `data`). */
165
+ name: string;
166
+ /** Best-effort plugin id the field originates from (`username`, `phone-number`, …); `undefined` for app `additionalFields`. */
167
+ plugin?: string;
168
+ required: boolean;
169
+ /** Coarse input kind the studio maps to a control (checkbox / number / date / text). */
170
+ type: "boolean" | "date" | "number" | "string";
171
+ unique: boolean;
172
+ }
173
+ /**
174
+ * A rich, read-only description of the deployment's auth configuration for the
175
+ * studio's config panel and dynamic create-user form. Unlike
176
+ * {@link AuthCapabilities} (five booleans that gate panels), this exposes *what*
177
+ * is configured — enabled plugins, email/password + social sign-in, the
178
+ * user-settable fields, organization sub-features (teams / custom roles), and
179
+ * the session + rate-limit policy — without ever leaking a secret.
180
+ */
181
+ interface AuthConfigInfo {
182
+ /** The same capability booleans {@link AuthAdmin.capabilities} returns, embedded so a single call drives the whole panel. */
183
+ capabilities: AuthCapabilities;
184
+ /** Whether email + password sign-in is enabled. */
185
+ emailAndPassword: boolean;
186
+ /** Organization plugin sub-features. */
187
+ organization: {
188
+ enabled: boolean; /** Custom roles / dynamic access control (`organizationRole` table present). */
189
+ roles: boolean;
190
+ /** Teams (`team` table present). */
191
+ teams: boolean;
192
+ };
193
+ /** Enabled better-auth plugin ids, sorted. */
194
+ plugins: string[];
195
+ /** Rate-limit policy (window is in seconds). */
196
+ rateLimit: {
197
+ enabled: boolean;
198
+ max?: number;
199
+ window?: number;
200
+ };
201
+ /** Session policy (all durations in seconds). */
202
+ session: {
203
+ cookieCache?: boolean;
204
+ expiresIn?: number;
205
+ freshAge?: number;
206
+ updateAge?: number;
207
+ };
208
+ /** Configured social/OAuth provider ids, sorted. */
209
+ socialProviders: string[];
210
+ /** User-settable extra fields for the create-user form (plugin + app `additionalFields`). */
211
+ userFields: AuthUserFieldSpec[];
212
+ }
127
213
  /** A scalar value usable in an adapter `where` clause / filter. */
128
214
  type WhereValue = boolean | number | string;
129
215
  /** Filtering / paging options for {@link AuthAdmin.listUsers}. */
@@ -159,6 +245,17 @@ interface ImpersonationResult {
159
245
  * surfaces the underlying adapter error.
160
246
  */
161
247
  interface AuthAdmin {
248
+ /** Directly add an existing user as an org member (server-side, no invitation/acceptance). */
249
+ addMember: (input: {
250
+ organizationId: string;
251
+ role?: string;
252
+ userId: string;
253
+ }) => Promise<AuthMember>;
254
+ /** Add a user to a team. */
255
+ addTeamMember: (input: {
256
+ teamId: string;
257
+ userId: string;
258
+ }) => Promise<AuthTeamMember>;
162
259
  banUser: (input: {
163
260
  expiresInSeconds?: number;
164
261
  reason?: string;
@@ -168,6 +265,27 @@ interface AuthAdmin {
168
265
  invitationId: string;
169
266
  }) => Promise<void>;
170
267
  capabilities: () => Promise<AuthCapabilities>;
268
+ /** Rich, read-only description of the auth configuration (plugins, fields, session policy, …). */
269
+ config: () => Promise<AuthConfigInfo>;
270
+ /** Create an organization; optionally seed an `owner` member for `ownerId`. */
271
+ createOrganization: (input: {
272
+ logo?: string;
273
+ metadata?: Record<string, unknown>;
274
+ name: string;
275
+ ownerId?: string;
276
+ slug?: string;
277
+ }) => Promise<AuthOrganization>;
278
+ /** Create a custom org role with a permission grant (a `resource → actions[]` map). */
279
+ createOrgRole: (input: {
280
+ organizationId: string;
281
+ permission: Record<string, string[]>;
282
+ role: string;
283
+ }) => Promise<AuthOrgRole>;
284
+ /** Create a team under an organization. */
285
+ createTeam: (input: {
286
+ name: string;
287
+ organizationId: string;
288
+ }) => Promise<AuthTeam>;
171
289
  createUser: (input: {
172
290
  data?: Record<string, unknown>;
173
291
  email: string;
@@ -175,6 +293,14 @@ interface AuthAdmin {
175
293
  password?: string;
176
294
  role?: string | string[];
177
295
  }) => Promise<AuthAdminUser>;
296
+ /** Delete an organization and cascade-delete its members, invitations, teams, and custom roles. */
297
+ deleteOrganization: (input: {
298
+ organizationId: string;
299
+ }) => Promise<void>;
300
+ /** Delete a custom org role. */
301
+ deleteOrgRole: (input: {
302
+ roleId: string;
303
+ }) => Promise<void>;
178
304
  deletePasskey: (input: {
179
305
  passkeyId: string;
180
306
  }) => Promise<void>;
@@ -184,6 +310,13 @@ interface AuthAdmin {
184
310
  impersonateUser: (input: {
185
311
  userId: string;
186
312
  }) => Promise<ImpersonationResult>;
313
+ /** Create a pending email invitation to an org (no acceptance side effects). */
314
+ inviteMember: (input: {
315
+ email: string;
316
+ inviterId?: string;
317
+ organizationId: string;
318
+ role?: string;
319
+ }) => Promise<AuthInvitation>;
187
320
  listAccounts: (input: {
188
321
  userId: string;
189
322
  }) => Promise<AuthAccount[]>;
@@ -201,6 +334,12 @@ interface AuthAdmin {
201
334
  limit?: number;
202
335
  offset?: number;
203
336
  }) => Promise<AuthPage<AuthOrganization>>;
337
+ /** List an org's custom roles. */
338
+ listOrgRoles: (options: {
339
+ limit?: number;
340
+ offset?: number;
341
+ organizationId: string;
342
+ }) => Promise<AuthPage<AuthOrgRole>>;
204
343
  listPasskeys: (input: {
205
344
  userId: string;
206
345
  }) => Promise<AuthPasskey[]>;
@@ -209,10 +348,30 @@ interface AuthAdmin {
209
348
  offset?: number;
210
349
  userId?: string;
211
350
  }) => Promise<AuthPage<AuthAdminSession>>;
351
+ /** List a team's members. */
352
+ listTeamMembers: (options: {
353
+ limit?: number;
354
+ offset?: number;
355
+ teamId: string;
356
+ }) => Promise<AuthPage<AuthTeamMember>>;
357
+ /** List an org's teams. */
358
+ listTeams: (options: {
359
+ limit?: number;
360
+ offset?: number;
361
+ organizationId: string;
362
+ }) => Promise<AuthPage<AuthTeam>>;
212
363
  listUsers: (options: ListUsersOptions) => Promise<AuthPage<AuthAdminUser>>;
213
364
  removeMember: (input: {
214
365
  memberId: string;
215
366
  }) => Promise<void>;
367
+ /** Delete a team and its memberships. */
368
+ removeTeam: (input: {
369
+ teamId: string;
370
+ }) => Promise<void>;
371
+ /** Remove a member from a team. */
372
+ removeTeamMember: (input: {
373
+ teamMemberId: string;
374
+ }) => Promise<void>;
216
375
  removeUser: (input: {
217
376
  userId: string;
218
377
  }) => Promise<void>;
@@ -237,6 +396,29 @@ interface AuthAdmin {
237
396
  accountId: string;
238
397
  userId: string;
239
398
  }) => Promise<void>;
399
+ /** Change a member's role. */
400
+ updateMemberRole: (input: {
401
+ memberId: string;
402
+ role: string | string[];
403
+ }) => Promise<AuthMember>;
404
+ /** Update an organization's name/slug/logo/metadata. */
405
+ updateOrganization: (input: {
406
+ logo?: string;
407
+ metadata?: Record<string, unknown>;
408
+ name?: string;
409
+ organizationId: string;
410
+ slug?: string;
411
+ }) => Promise<AuthOrganization>;
412
+ /** Replace a custom org role's permission grant. */
413
+ updateOrgRole: (input: {
414
+ permission: Record<string, string[]>;
415
+ roleId: string;
416
+ }) => Promise<AuthOrgRole>;
417
+ /** Rename a team. */
418
+ updateTeam: (input: {
419
+ name: string;
420
+ teamId: string;
421
+ }) => Promise<AuthTeam>;
240
422
  updateUser: (input: {
241
423
  data: Record<string, unknown>;
242
424
  userId: string;
package/dist/index.d.ts CHANGED
@@ -91,6 +91,35 @@ interface AuthInvitation {
91
91
  role?: null | string;
92
92
  status?: null | string;
93
93
  }
94
+ /** One team row (from the `organization` plugin with `teams.enabled`). */
95
+ interface AuthTeam {
96
+ [key: string]: unknown;
97
+ createdAt?: AuthTimestamp;
98
+ id: string;
99
+ name?: null | string;
100
+ organizationId: string;
101
+ }
102
+ /** One team-membership row (teams). */
103
+ interface AuthTeamMember {
104
+ [key: string]: unknown;
105
+ createdAt?: AuthTimestamp;
106
+ id: string;
107
+ teamId: string;
108
+ userId: string;
109
+ }
110
+ /**
111
+ * One custom organization role (from the organization plugin's dynamic
112
+ * access-control). `permission` is a JSON string of a `resource → actions[]` map
113
+ * as stored; the studio parses it for display/editing.
114
+ */
115
+ interface AuthOrgRole {
116
+ [key: string]: unknown;
117
+ createdAt?: AuthTimestamp;
118
+ id: string;
119
+ organizationId: string;
120
+ permission?: null | string;
121
+ role?: null | string;
122
+ }
94
123
  /** One registered passkey. Credential secrets (`publicKey`) are stripped. */
95
124
  interface AuthPasskey {
96
125
  [key: string]: unknown;
@@ -124,6 +153,63 @@ interface AuthCapabilities {
124
153
  /** The `two-factor` plugin: per-user 2FA status / disable. */
125
154
  twoFactor: boolean;
126
155
  }
156
+ /**
157
+ * One app/plugin-defined field the create-user form should render, derived from
158
+ * the merged better-auth `user` table (core + plugin + `additionalFields`). Only
159
+ * user-settable columns are surfaced — server-managed flags (`input: false`),
160
+ * foreign keys (`references`), and the core columns the form already handles
161
+ * (`email`/`name`/`role`/ban state/…) are filtered out upstream.
162
+ */
163
+ interface AuthUserFieldSpec {
164
+ /** Logical field name (the key passed back in `createUser`'s `data`). */
165
+ name: string;
166
+ /** Best-effort plugin id the field originates from (`username`, `phone-number`, …); `undefined` for app `additionalFields`. */
167
+ plugin?: string;
168
+ required: boolean;
169
+ /** Coarse input kind the studio maps to a control (checkbox / number / date / text). */
170
+ type: "boolean" | "date" | "number" | "string";
171
+ unique: boolean;
172
+ }
173
+ /**
174
+ * A rich, read-only description of the deployment's auth configuration for the
175
+ * studio's config panel and dynamic create-user form. Unlike
176
+ * {@link AuthCapabilities} (five booleans that gate panels), this exposes *what*
177
+ * is configured — enabled plugins, email/password + social sign-in, the
178
+ * user-settable fields, organization sub-features (teams / custom roles), and
179
+ * the session + rate-limit policy — without ever leaking a secret.
180
+ */
181
+ interface AuthConfigInfo {
182
+ /** The same capability booleans {@link AuthAdmin.capabilities} returns, embedded so a single call drives the whole panel. */
183
+ capabilities: AuthCapabilities;
184
+ /** Whether email + password sign-in is enabled. */
185
+ emailAndPassword: boolean;
186
+ /** Organization plugin sub-features. */
187
+ organization: {
188
+ enabled: boolean; /** Custom roles / dynamic access control (`organizationRole` table present). */
189
+ roles: boolean;
190
+ /** Teams (`team` table present). */
191
+ teams: boolean;
192
+ };
193
+ /** Enabled better-auth plugin ids, sorted. */
194
+ plugins: string[];
195
+ /** Rate-limit policy (window is in seconds). */
196
+ rateLimit: {
197
+ enabled: boolean;
198
+ max?: number;
199
+ window?: number;
200
+ };
201
+ /** Session policy (all durations in seconds). */
202
+ session: {
203
+ cookieCache?: boolean;
204
+ expiresIn?: number;
205
+ freshAge?: number;
206
+ updateAge?: number;
207
+ };
208
+ /** Configured social/OAuth provider ids, sorted. */
209
+ socialProviders: string[];
210
+ /** User-settable extra fields for the create-user form (plugin + app `additionalFields`). */
211
+ userFields: AuthUserFieldSpec[];
212
+ }
127
213
  /** A scalar value usable in an adapter `where` clause / filter. */
128
214
  type WhereValue = boolean | number | string;
129
215
  /** Filtering / paging options for {@link AuthAdmin.listUsers}. */
@@ -159,6 +245,17 @@ interface ImpersonationResult {
159
245
  * surfaces the underlying adapter error.
160
246
  */
161
247
  interface AuthAdmin {
248
+ /** Directly add an existing user as an org member (server-side, no invitation/acceptance). */
249
+ addMember: (input: {
250
+ organizationId: string;
251
+ role?: string;
252
+ userId: string;
253
+ }) => Promise<AuthMember>;
254
+ /** Add a user to a team. */
255
+ addTeamMember: (input: {
256
+ teamId: string;
257
+ userId: string;
258
+ }) => Promise<AuthTeamMember>;
162
259
  banUser: (input: {
163
260
  expiresInSeconds?: number;
164
261
  reason?: string;
@@ -168,6 +265,27 @@ interface AuthAdmin {
168
265
  invitationId: string;
169
266
  }) => Promise<void>;
170
267
  capabilities: () => Promise<AuthCapabilities>;
268
+ /** Rich, read-only description of the auth configuration (plugins, fields, session policy, …). */
269
+ config: () => Promise<AuthConfigInfo>;
270
+ /** Create an organization; optionally seed an `owner` member for `ownerId`. */
271
+ createOrganization: (input: {
272
+ logo?: string;
273
+ metadata?: Record<string, unknown>;
274
+ name: string;
275
+ ownerId?: string;
276
+ slug?: string;
277
+ }) => Promise<AuthOrganization>;
278
+ /** Create a custom org role with a permission grant (a `resource → actions[]` map). */
279
+ createOrgRole: (input: {
280
+ organizationId: string;
281
+ permission: Record<string, string[]>;
282
+ role: string;
283
+ }) => Promise<AuthOrgRole>;
284
+ /** Create a team under an organization. */
285
+ createTeam: (input: {
286
+ name: string;
287
+ organizationId: string;
288
+ }) => Promise<AuthTeam>;
171
289
  createUser: (input: {
172
290
  data?: Record<string, unknown>;
173
291
  email: string;
@@ -175,6 +293,14 @@ interface AuthAdmin {
175
293
  password?: string;
176
294
  role?: string | string[];
177
295
  }) => Promise<AuthAdminUser>;
296
+ /** Delete an organization and cascade-delete its members, invitations, teams, and custom roles. */
297
+ deleteOrganization: (input: {
298
+ organizationId: string;
299
+ }) => Promise<void>;
300
+ /** Delete a custom org role. */
301
+ deleteOrgRole: (input: {
302
+ roleId: string;
303
+ }) => Promise<void>;
178
304
  deletePasskey: (input: {
179
305
  passkeyId: string;
180
306
  }) => Promise<void>;
@@ -184,6 +310,13 @@ interface AuthAdmin {
184
310
  impersonateUser: (input: {
185
311
  userId: string;
186
312
  }) => Promise<ImpersonationResult>;
313
+ /** Create a pending email invitation to an org (no acceptance side effects). */
314
+ inviteMember: (input: {
315
+ email: string;
316
+ inviterId?: string;
317
+ organizationId: string;
318
+ role?: string;
319
+ }) => Promise<AuthInvitation>;
187
320
  listAccounts: (input: {
188
321
  userId: string;
189
322
  }) => Promise<AuthAccount[]>;
@@ -201,6 +334,12 @@ interface AuthAdmin {
201
334
  limit?: number;
202
335
  offset?: number;
203
336
  }) => Promise<AuthPage<AuthOrganization>>;
337
+ /** List an org's custom roles. */
338
+ listOrgRoles: (options: {
339
+ limit?: number;
340
+ offset?: number;
341
+ organizationId: string;
342
+ }) => Promise<AuthPage<AuthOrgRole>>;
204
343
  listPasskeys: (input: {
205
344
  userId: string;
206
345
  }) => Promise<AuthPasskey[]>;
@@ -209,10 +348,30 @@ interface AuthAdmin {
209
348
  offset?: number;
210
349
  userId?: string;
211
350
  }) => Promise<AuthPage<AuthAdminSession>>;
351
+ /** List a team's members. */
352
+ listTeamMembers: (options: {
353
+ limit?: number;
354
+ offset?: number;
355
+ teamId: string;
356
+ }) => Promise<AuthPage<AuthTeamMember>>;
357
+ /** List an org's teams. */
358
+ listTeams: (options: {
359
+ limit?: number;
360
+ offset?: number;
361
+ organizationId: string;
362
+ }) => Promise<AuthPage<AuthTeam>>;
212
363
  listUsers: (options: ListUsersOptions) => Promise<AuthPage<AuthAdminUser>>;
213
364
  removeMember: (input: {
214
365
  memberId: string;
215
366
  }) => Promise<void>;
367
+ /** Delete a team and its memberships. */
368
+ removeTeam: (input: {
369
+ teamId: string;
370
+ }) => Promise<void>;
371
+ /** Remove a member from a team. */
372
+ removeTeamMember: (input: {
373
+ teamMemberId: string;
374
+ }) => Promise<void>;
216
375
  removeUser: (input: {
217
376
  userId: string;
218
377
  }) => Promise<void>;
@@ -237,6 +396,29 @@ interface AuthAdmin {
237
396
  accountId: string;
238
397
  userId: string;
239
398
  }) => Promise<void>;
399
+ /** Change a member's role. */
400
+ updateMemberRole: (input: {
401
+ memberId: string;
402
+ role: string | string[];
403
+ }) => Promise<AuthMember>;
404
+ /** Update an organization's name/slug/logo/metadata. */
405
+ updateOrganization: (input: {
406
+ logo?: string;
407
+ metadata?: Record<string, unknown>;
408
+ name?: string;
409
+ organizationId: string;
410
+ slug?: string;
411
+ }) => Promise<AuthOrganization>;
412
+ /** Replace a custom org role's permission grant. */
413
+ updateOrgRole: (input: {
414
+ permission: Record<string, string[]>;
415
+ roleId: string;
416
+ }) => Promise<AuthOrgRole>;
417
+ /** Rename a team. */
418
+ updateTeam: (input: {
419
+ name: string;
420
+ teamId: string;
421
+ }) => Promise<AuthTeam>;
240
422
  updateUser: (input: {
241
423
  data: Record<string, unknown>;
242
424
  userId: string;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lunoraAuthAdapter, lunoraD1Adapter } from './adapter.mjs';
2
- export { LunoraAuthAdminError, createAuthAdmin } from './packem_shared/LunoraAuthAdminError-BpVImgEy.mjs';
2
+ export { LunoraAuthAdminError, createAuthAdmin } from './packem_shared/LunoraAuthAdminError-D4L7n6gN.mjs';
3
3
  export { createAuth, resolveAuthOptions } from './packem_shared/createAuth-BVMMllTm.mjs';
4
4
  export { DEFAULT_AUTH_BASE_PATH, handleAuthRequest } from './packem_shared/DEFAULT_AUTH_BASE_PATH-DjcUWEQl.mjs';
5
5
  export { LunoraAuthHeadersError, withAuthPlugins } from './middleware.mjs';
@@ -0,0 +1,510 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { getAuthTables } from 'better-auth/db';
3
+
4
+ class LunoraAuthAdminError extends LunoraError {
5
+ constructor(message, code) {
6
+ super(code, message, { name: "LunoraAuthAdminError" });
7
+ }
8
+ }
9
+ const DEFAULT_LIMIT = 50;
10
+ const MAX_LIMIT = 500;
11
+ const DEFAULT_IMPERSONATION_SECONDS = 3600;
12
+ const MAX_IMPERSONATION_SECONDS = DEFAULT_IMPERSONATION_SECONDS * 24;
13
+ const MAX_BAN_SECONDS = 100 * 365 * 24 * 60 * 60;
14
+ const INVITATION_TTL_MS = 48 * 60 * 60 * 1e3;
15
+ const SENSITIVE_FIELDS = /* @__PURE__ */ new Set(["accessToken", "backupCodes", "idToken", "password", "publicKey", "refreshToken", "secret", "token"]);
16
+ const clampLimit = (limit) => Math.min(Math.max(Math.trunc(limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT);
17
+ const clampOffset = (offset) => Math.max(0, Math.trunc(offset ?? 0));
18
+ const normalizeRow = (row) => {
19
+ const out = {};
20
+ for (const [key, value] of Object.entries(row)) {
21
+ if (SENSITIVE_FIELDS.has(key)) {
22
+ continue;
23
+ }
24
+ out[key] = value instanceof Date ? value.getTime() : value;
25
+ }
26
+ return out;
27
+ };
28
+ const serializeRole = (role) => Array.isArray(role) ? role.join(",") : role;
29
+ const slugify = (value) => value.toLowerCase().replaceAll(/[^\da-z]+/g, "-").replaceAll(/^-|-$/g, "");
30
+ const CORE_USER_FIELDS = /* @__PURE__ */ new Set(["banExpires", "banned", "banReason", "createdAt", "email", "emailVerified", "id", "name", "role", "updatedAt"]);
31
+ const USER_FIELD_PLUGIN = {
32
+ displayUsername: "username",
33
+ phoneNumber: "phone-number",
34
+ phoneNumberVerified: "phone-number",
35
+ username: "username"
36
+ };
37
+ const mapUserFieldType = (type) => {
38
+ if (type === "boolean") {
39
+ return "boolean";
40
+ }
41
+ if (type === "date") {
42
+ return "date";
43
+ }
44
+ if (type === "number") {
45
+ return "number";
46
+ }
47
+ return "string";
48
+ };
49
+ const buildUserFields = (userFields) => {
50
+ const out = [];
51
+ for (const [name, attribute] of Object.entries(userFields)) {
52
+ if (attribute.input === false || attribute.references !== void 0 || CORE_USER_FIELDS.has(name)) {
53
+ continue;
54
+ }
55
+ out.push({
56
+ name,
57
+ plugin: USER_FIELD_PLUGIN[name],
58
+ required: attribute.required === true,
59
+ type: mapUserFieldType(attribute.type),
60
+ unique: attribute.unique === true
61
+ });
62
+ }
63
+ return out;
64
+ };
65
+ const asAdminError = (error) => {
66
+ if (error instanceof LunoraAuthAdminError) {
67
+ return error;
68
+ }
69
+ const candidate = error;
70
+ const code = candidate?.body?.code ?? candidate?.code ?? "AUTH_ADMIN_ERROR";
71
+ const message = candidate?.body?.message ?? candidate?.message ?? "auth admin operation failed";
72
+ return new LunoraAuthAdminError(message, code);
73
+ };
74
+ const createAuthAdmin = (auth, options = {}) => {
75
+ const context = auth.$context;
76
+ const features = options.features ?? {};
77
+ const withContext = async (function_) => {
78
+ try {
79
+ return await function_(await context);
80
+ } catch (error) {
81
+ throw asAdminError(error);
82
+ }
83
+ };
84
+ const toUser = (row) => normalizeRow(row);
85
+ const page = async (context_, model, options_) => {
86
+ const where = options_.where && options_.where.length > 0 ? options_.where : void 0;
87
+ const [rows, total] = await Promise.all([
88
+ context_.adapter.findMany({
89
+ limit: clampLimit(options_.limit),
90
+ model,
91
+ offset: clampOffset(options_.offset),
92
+ sortBy: options_.sortBy,
93
+ where
94
+ }),
95
+ context_.adapter.count({ model, where })
96
+ ]);
97
+ return { rows: rows.map((row) => normalizeRow(row)), total };
98
+ };
99
+ return {
100
+ banUser: ({ expiresInSeconds, reason, userId }) => withContext(async (context_) => {
101
+ const seconds = typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) ? Math.min(Math.trunc(expiresInSeconds), MAX_BAN_SECONDS) : 0;
102
+ const banExpires = seconds > 0 ? new Date(Date.now() + seconds * 1e3) : void 0;
103
+ const user = await context_.internalAdapter.updateUser(userId, {
104
+ banExpires,
105
+ banned: true,
106
+ banReason: reason ?? "No reason"
107
+ });
108
+ await context_.internalAdapter.deleteUserSessions(userId);
109
+ return toUser(user);
110
+ }),
111
+ cancelInvitation: ({ invitationId }) => withContext(async (context_) => {
112
+ await context_.adapter.delete({ model: "invitation", where: [{ field: "id", value: invitationId }] });
113
+ }),
114
+ capabilities: () => withContext((context_) => {
115
+ const ids = new Set((context_.options.plugins ?? []).map((plugin) => plugin.id));
116
+ const has = (id) => ids.has(id);
117
+ return Promise.resolve({
118
+ accounts: features.accounts ?? true,
119
+ admin: features.admin ?? has("admin"),
120
+ organization: features.organization ?? has("organization"),
121
+ passkey: features.passkey ?? has("passkey"),
122
+ twoFactor: features.twoFactor ?? has("two-factor")
123
+ });
124
+ }),
125
+ // ── Directly add an existing user to an org (no invitation/acceptance). ──
126
+ addMember: ({ organizationId, role, userId }) => withContext(async (context_) => {
127
+ const member = await context_.adapter.create({
128
+ data: { createdAt: /* @__PURE__ */ new Date(), organizationId, role: role === void 0 || role === "" ? "member" : role, userId },
129
+ model: "member"
130
+ });
131
+ return normalizeRow(member);
132
+ }),
133
+ addTeamMember: ({ teamId, userId }) => withContext(async (context_) => {
134
+ const teamMember = await context_.adapter.create({
135
+ data: { createdAt: /* @__PURE__ */ new Date(), teamId, userId },
136
+ model: "teamMember"
137
+ });
138
+ return normalizeRow(teamMember);
139
+ }),
140
+ // Rich introspection for the config panel + dynamic create-user form. Reads
141
+ // only from the resolved better-auth options (no DB, no secrets).
142
+ config: () => withContext((context_) => {
143
+ const authOptions = context_.options;
144
+ const ids = new Set((authOptions.plugins ?? []).map((plugin) => plugin.id));
145
+ const has = (id) => ids.has(id);
146
+ const capabilities = {
147
+ accounts: features.accounts ?? true,
148
+ admin: features.admin ?? has("admin"),
149
+ organization: features.organization ?? has("organization"),
150
+ passkey: features.passkey ?? has("passkey"),
151
+ twoFactor: features.twoFactor ?? has("two-factor")
152
+ };
153
+ const tables = getAuthTables(authOptions);
154
+ const session = authOptions.session ?? {};
155
+ const rateLimit = authOptions.rateLimit ?? {};
156
+ return Promise.resolve({
157
+ capabilities,
158
+ emailAndPassword: authOptions.emailAndPassword?.enabled ?? false,
159
+ organization: {
160
+ enabled: capabilities.organization,
161
+ roles: Boolean(tables["organizationRole"]),
162
+ teams: Boolean(tables["team"])
163
+ },
164
+ plugins: [...ids].toSorted((a, b) => a.localeCompare(b)),
165
+ rateLimit: { enabled: rateLimit.enabled ?? false, max: rateLimit.max, window: rateLimit.window },
166
+ session: {
167
+ cookieCache: session.cookieCache?.enabled,
168
+ expiresIn: session.expiresIn,
169
+ freshAge: session.freshAge,
170
+ updateAge: session.updateAge
171
+ },
172
+ socialProviders: Object.keys(authOptions.socialProviders ?? {}).toSorted((a, b) => a.localeCompare(b)),
173
+ userFields: buildUserFields(tables["user"]?.fields ?? {})
174
+ });
175
+ }),
176
+ createOrganization: ({ logo, metadata, name, ownerId, slug }) => withContext(async (context_) => {
177
+ const finalSlug = slug !== void 0 && slug !== "" ? slugify(slug) : slugify(name);
178
+ if (finalSlug === "") {
179
+ throw new LunoraAuthAdminError("could not derive a slug from the organization name", "ORG_SLUG_INVALID");
180
+ }
181
+ const existing = await context_.adapter.findOne({
182
+ model: "organization",
183
+ where: [{ field: "slug", value: finalSlug }]
184
+ });
185
+ if (existing) {
186
+ throw new LunoraAuthAdminError("an organization with this slug already exists", "ORG_SLUG_TAKEN");
187
+ }
188
+ const organization = await context_.adapter.create({
189
+ data: {
190
+ createdAt: /* @__PURE__ */ new Date(),
191
+ logo: logo === void 0 || logo === "" ? void 0 : logo,
192
+ metadata: metadata === void 0 ? void 0 : JSON.stringify(metadata),
193
+ name,
194
+ slug: finalSlug
195
+ },
196
+ model: "organization"
197
+ });
198
+ if (ownerId !== void 0 && ownerId !== "") {
199
+ await context_.adapter.create({
200
+ data: { createdAt: /* @__PURE__ */ new Date(), organizationId: organization.id, role: "owner", userId: ownerId },
201
+ model: "member"
202
+ });
203
+ }
204
+ return normalizeRow(organization);
205
+ }),
206
+ createOrgRole: ({ organizationId, permission, role }) => withContext(async (context_) => {
207
+ const created = await context_.adapter.create({
208
+ data: { createdAt: /* @__PURE__ */ new Date(), organizationId, permission: JSON.stringify(permission), role },
209
+ model: "organizationRole"
210
+ });
211
+ return normalizeRow(created);
212
+ }),
213
+ createTeam: ({ name, organizationId }) => withContext(async (context_) => {
214
+ const team = await context_.adapter.create({
215
+ data: { createdAt: /* @__PURE__ */ new Date(), name, organizationId },
216
+ model: "team"
217
+ });
218
+ return normalizeRow(team);
219
+ }),
220
+ deleteOrganization: ({ organizationId }) => withContext(async (context_) => {
221
+ const tables = getAuthTables(context_.options);
222
+ await context_.adapter.deleteMany({ model: "member", where: [{ field: "organizationId", value: organizationId }] });
223
+ await context_.adapter.deleteMany({ model: "invitation", where: [{ field: "organizationId", value: organizationId }] });
224
+ if (tables["team"]) {
225
+ const teams = await context_.adapter.findMany({
226
+ model: "team",
227
+ where: [{ field: "organizationId", value: organizationId }]
228
+ });
229
+ for (const team of teams) {
230
+ await context_.adapter.deleteMany({ model: "teamMember", where: [{ field: "teamId", value: team.id }] });
231
+ }
232
+ await context_.adapter.deleteMany({ model: "team", where: [{ field: "organizationId", value: organizationId }] });
233
+ }
234
+ if (tables["organizationRole"]) {
235
+ await context_.adapter.deleteMany({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId }] });
236
+ }
237
+ await context_.adapter.delete({ model: "organization", where: [{ field: "id", value: organizationId }] });
238
+ }),
239
+ deleteOrgRole: ({ roleId }) => withContext(async (context_) => {
240
+ await context_.adapter.delete({ model: "organizationRole", where: [{ field: "id", value: roleId }] });
241
+ }),
242
+ // Create a pending email invitation. `inviterId` is DB-required; when the
243
+ // caller omits it, attribute the invite to the org's owner (else any member).
244
+ inviteMember: ({ email, inviterId, organizationId, role }) => withContext(async (context_) => {
245
+ let resolvedInviter = inviterId;
246
+ if (resolvedInviter === void 0 || resolvedInviter === "") {
247
+ const members = await context_.adapter.findMany({
248
+ model: "member",
249
+ where: [{ field: "organizationId", value: organizationId }]
250
+ });
251
+ const owner = members.find((member) => typeof member.role === "string" && member.role.includes("owner"));
252
+ resolvedInviter = (owner ?? members[0])?.userId;
253
+ }
254
+ if (resolvedInviter === void 0 || resolvedInviter === "") {
255
+ throw new LunoraAuthAdminError("provide an inviter — the organization has no members to attribute the invitation to", "INVITER_REQUIRED");
256
+ }
257
+ const invitation = await context_.adapter.create({
258
+ data: {
259
+ createdAt: /* @__PURE__ */ new Date(),
260
+ email: email.toLowerCase(),
261
+ expiresAt: new Date(Date.now() + INVITATION_TTL_MS),
262
+ inviterId: resolvedInviter,
263
+ organizationId,
264
+ role: role === void 0 || role === "" ? "member" : role,
265
+ status: "pending"
266
+ },
267
+ model: "invitation"
268
+ });
269
+ return normalizeRow(invitation);
270
+ }),
271
+ listOrgRoles: ({ limit, offset, organizationId }) => withContext(
272
+ (context_) => page(context_, "organizationRole", {
273
+ limit,
274
+ offset,
275
+ sortBy: { direction: "desc", field: "createdAt" },
276
+ where: [{ field: "organizationId", value: organizationId }]
277
+ })
278
+ ),
279
+ listTeamMembers: ({ limit, offset, teamId }) => withContext(
280
+ (context_) => page(context_, "teamMember", {
281
+ limit,
282
+ offset,
283
+ where: [{ field: "teamId", value: teamId }]
284
+ })
285
+ ),
286
+ listTeams: ({ limit, offset, organizationId }) => withContext(
287
+ (context_) => page(context_, "team", {
288
+ limit,
289
+ offset,
290
+ sortBy: { direction: "desc", field: "createdAt" },
291
+ where: [{ field: "organizationId", value: organizationId }]
292
+ })
293
+ ),
294
+ removeTeam: ({ teamId }) => withContext(async (context_) => {
295
+ await context_.adapter.deleteMany({ model: "teamMember", where: [{ field: "teamId", value: teamId }] });
296
+ await context_.adapter.delete({ model: "team", where: [{ field: "id", value: teamId }] });
297
+ }),
298
+ removeTeamMember: ({ teamMemberId }) => withContext(async (context_) => {
299
+ await context_.adapter.delete({ model: "teamMember", where: [{ field: "id", value: teamMemberId }] });
300
+ }),
301
+ updateMemberRole: ({ memberId, role }) => withContext(async (context_) => {
302
+ const member = await context_.adapter.update({
303
+ model: "member",
304
+ update: { role: serializeRole(role) },
305
+ where: [{ field: "id", value: memberId }]
306
+ });
307
+ return normalizeRow(member ?? { id: memberId, role: serializeRole(role) });
308
+ }),
309
+ updateOrganization: ({ logo, metadata, name, organizationId, slug }) => withContext(async (context_) => {
310
+ const update = {};
311
+ if (name !== void 0) {
312
+ update["name"] = name;
313
+ }
314
+ if (slug !== void 0 && slug !== "") {
315
+ update["slug"] = slugify(slug);
316
+ }
317
+ if (logo !== void 0) {
318
+ update["logo"] = logo === "" ? void 0 : logo;
319
+ }
320
+ if (metadata !== void 0) {
321
+ update["metadata"] = JSON.stringify(metadata);
322
+ }
323
+ if (Object.keys(update).length === 0) {
324
+ return normalizeRow({ id: organizationId });
325
+ }
326
+ const organization = await context_.adapter.update({
327
+ model: "organization",
328
+ update,
329
+ where: [{ field: "id", value: organizationId }]
330
+ });
331
+ return normalizeRow(organization ?? { id: organizationId });
332
+ }),
333
+ updateOrgRole: ({ permission, roleId }) => withContext(async (context_) => {
334
+ const updated = await context_.adapter.update({
335
+ model: "organizationRole",
336
+ update: { permission: JSON.stringify(permission), updatedAt: /* @__PURE__ */ new Date() },
337
+ where: [{ field: "id", value: roleId }]
338
+ });
339
+ return normalizeRow(updated ?? { id: roleId, permission: JSON.stringify(permission) });
340
+ }),
341
+ updateTeam: ({ name, teamId }) => withContext(async (context_) => {
342
+ const team = await context_.adapter.update({
343
+ model: "team",
344
+ update: { name, updatedAt: /* @__PURE__ */ new Date() },
345
+ where: [{ field: "id", value: teamId }]
346
+ });
347
+ return normalizeRow(team ?? { id: teamId, name });
348
+ }),
349
+ // The one op that genuinely builds a row rather than mutating one. Replicates
350
+ // the plugin's create-user handler over `internalAdapter` (lowercase + dedupe
351
+ // email, create the row, then link a credential account when a password is given).
352
+ createUser: ({ data, email, name, password, role }) => withContext(async (context_) => {
353
+ const normalizedEmail = email.toLowerCase();
354
+ if (await context_.internalAdapter.findUserByEmail(normalizedEmail)) {
355
+ throw new LunoraAuthAdminError("a user with this email already exists", "USER_ALREADY_EXISTS");
356
+ }
357
+ const user = await context_.internalAdapter.createUser({
358
+ email: normalizedEmail,
359
+ name,
360
+ role: role === void 0 ? void 0 : serializeRole(role),
361
+ ...data
362
+ });
363
+ if (password !== void 0 && password !== "") {
364
+ const hashed = await context_.password.hash(password);
365
+ await context_.internalAdapter.linkAccount({
366
+ accountId: user.id,
367
+ password: hashed,
368
+ providerId: "credential",
369
+ userId: user.id
370
+ });
371
+ }
372
+ return toUser(user);
373
+ }),
374
+ deletePasskey: ({ passkeyId }) => withContext(async (context_) => {
375
+ await context_.adapter.delete({ model: "passkey", where: [{ field: "id", value: passkeyId }] });
376
+ }),
377
+ disableTwoFactor: ({ userId }) => withContext(async (context_) => {
378
+ await context_.adapter.deleteMany({ model: "twoFactor", where: [{ field: "userId", value: userId }] });
379
+ await context_.internalAdapter.updateUser(userId, { twoFactorEnabled: false });
380
+ }),
381
+ impersonateUser: ({ userId }) => withContext(async (context_) => {
382
+ const user = await context_.internalAdapter.findUserById(userId);
383
+ if (!user) {
384
+ throw new LunoraAuthAdminError("user not found", "USER_NOT_FOUND");
385
+ }
386
+ const rawSeconds = options.impersonationSeconds;
387
+ let ttlSeconds = DEFAULT_IMPERSONATION_SECONDS;
388
+ if (rawSeconds !== void 0) {
389
+ if (!Number.isInteger(rawSeconds) || !Number.isFinite(rawSeconds) || rawSeconds <= 0) {
390
+ throw new LunoraAuthAdminError("impersonationSeconds must be a positive finite integer", "INVALID_IMPERSONATION_SECONDS");
391
+ }
392
+ ttlSeconds = Math.min(rawSeconds, MAX_IMPERSONATION_SECONDS);
393
+ }
394
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
395
+ const session = await context_.internalAdapter.createSession(
396
+ userId,
397
+ true,
398
+ { expiresAt, impersonatedBy: options.impersonatedBy ?? userId },
399
+ true
400
+ );
401
+ return {
402
+ expiresAt: session.expiresAt instanceof Date ? session.expiresAt.getTime() : expiresAt.getTime(),
403
+ token: session.token,
404
+ user: toUser(user)
405
+ };
406
+ }),
407
+ listAccounts: ({ userId }) => withContext(async (context_) => {
408
+ const rows = await context_.adapter.findMany({ model: "account", where: [{ field: "userId", value: userId }] });
409
+ return rows.map((row) => normalizeRow(row));
410
+ }),
411
+ listInvitations: ({ limit, offset, organizationId }) => withContext(
412
+ (context_) => page(context_, "invitation", {
413
+ limit,
414
+ offset,
415
+ where: [{ field: "organizationId", value: organizationId }]
416
+ })
417
+ ),
418
+ listMembers: ({ limit, offset, organizationId }) => withContext(
419
+ (context_) => page(context_, "member", {
420
+ limit,
421
+ offset,
422
+ sortBy: { direction: "desc", field: "createdAt" },
423
+ where: [{ field: "organizationId", value: organizationId }]
424
+ })
425
+ ),
426
+ listOrganizations: ({ limit, offset }) => withContext((context_) => page(context_, "organization", { limit, offset, sortBy: { direction: "desc", field: "createdAt" } })),
427
+ listPasskeys: ({ userId }) => withContext(async (context_) => {
428
+ const rows = await context_.adapter.findMany({ model: "passkey", where: [{ field: "userId", value: userId }] });
429
+ return rows.map((row) => normalizeRow(row));
430
+ }),
431
+ listSessions: ({ limit, offset, userId }) => withContext(
432
+ (context_) => page(context_, "session", {
433
+ limit,
434
+ offset,
435
+ sortBy: { direction: "desc", field: "createdAt" },
436
+ where: userId === void 0 || userId === "" ? void 0 : [{ field: "userId", value: userId }]
437
+ })
438
+ ),
439
+ listUsers: ({ filterField, filterValue, limit, offset, search, searchField, sortBy, sortDirection }) => withContext((context_) => {
440
+ const where = [];
441
+ if (search !== void 0 && search !== "") {
442
+ where.push({ field: searchField ?? "email", operator: "contains", value: search });
443
+ }
444
+ if (filterValue !== void 0) {
445
+ where.push({ field: filterField ?? "email", operator: "eq", value: filterValue });
446
+ }
447
+ return page(context_, "user", {
448
+ limit,
449
+ offset,
450
+ sortBy: { direction: sortDirection ?? "desc", field: sortBy ?? "createdAt" },
451
+ where
452
+ });
453
+ }),
454
+ removeMember: ({ memberId }) => withContext(async (context_) => {
455
+ await context_.adapter.delete({ model: "member", where: [{ field: "id", value: memberId }] });
456
+ }),
457
+ removeUser: ({ userId }) => withContext(async (context_) => {
458
+ await context_.internalAdapter.deleteUserSessions(userId);
459
+ await context_.internalAdapter.deleteUser(userId);
460
+ }),
461
+ // Keyed on the session *id*, not its token: tokens are bearer credentials we
462
+ // deliberately never surface to the studio. Resolve the row to recover its
463
+ // token, then delete via `internalAdapter.deleteSession` — which also clears
464
+ // secondary (KV) storage, unlike a raw `adapter.delete` on the DB row.
465
+ revokeUserSession: ({ sessionId }) => withContext(async (context_) => {
466
+ const session = await context_.adapter.findOne({ model: "session", where: [{ field: "id", value: sessionId }] });
467
+ if (session?.token) {
468
+ await context_.internalAdapter.deleteSession(session.token);
469
+ }
470
+ }),
471
+ revokeUserSessions: ({ userId }) => withContext(async (context_) => {
472
+ await context_.internalAdapter.deleteUserSessions(userId);
473
+ }),
474
+ setRole: ({ role, userId }) => withContext(async (context_) => {
475
+ const user = await context_.internalAdapter.updateUser(userId, { role: serializeRole(role) });
476
+ return toUser(user);
477
+ }),
478
+ setUserPassword: ({ newPassword, userId }) => withContext(async (context_) => {
479
+ const min = context_.password.config.minPasswordLength;
480
+ const max = context_.password.config.maxPasswordLength;
481
+ if (newPassword.length < min) {
482
+ throw new LunoraAuthAdminError(`password must be at least ${min.toString()} characters`, "PASSWORD_TOO_SHORT");
483
+ }
484
+ if (newPassword.length > max) {
485
+ throw new LunoraAuthAdminError(`password must be at most ${max.toString()} characters`, "PASSWORD_TOO_LONG");
486
+ }
487
+ const hashed = await context_.password.hash(newPassword);
488
+ await context_.internalAdapter.updatePassword(userId, hashed);
489
+ }),
490
+ unbanUser: ({ userId }) => withContext(async (context_) => {
491
+ const user = await context_.internalAdapter.updateUser(userId, { banExpires: null, banned: false, banReason: null });
492
+ return toUser(user);
493
+ }),
494
+ unlinkAccount: ({ accountId, userId }) => withContext(async (context_) => {
495
+ await context_.adapter.delete({
496
+ model: "account",
497
+ where: [
498
+ { field: "id", value: accountId },
499
+ { connector: "AND", field: "userId", value: userId }
500
+ ]
501
+ });
502
+ }),
503
+ updateUser: ({ data, userId }) => withContext(async (context_) => {
504
+ const user = await context_.internalAdapter.updateUser(userId, data);
505
+ return toUser(user);
506
+ })
507
+ };
508
+ };
509
+
510
+ export { LunoraAuthAdminError, createAuthAdmin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/auth",
3
- "version": "1.0.0-alpha.20",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "Auth for Lunora — a thin better-auth wrapper: email/password, OAuth, plugins, D1-backed",
5
5
  "keywords": [
6
6
  "auth",
@@ -1,248 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
-
3
- class LunoraAuthAdminError extends LunoraError {
4
- constructor(message, code) {
5
- super(code, message, { name: "LunoraAuthAdminError" });
6
- }
7
- }
8
- const DEFAULT_LIMIT = 50;
9
- const MAX_LIMIT = 500;
10
- const DEFAULT_IMPERSONATION_SECONDS = 3600;
11
- const MAX_IMPERSONATION_SECONDS = DEFAULT_IMPERSONATION_SECONDS * 24;
12
- const MAX_BAN_SECONDS = 100 * 365 * 24 * 60 * 60;
13
- const SENSITIVE_FIELDS = /* @__PURE__ */ new Set(["accessToken", "backupCodes", "idToken", "password", "publicKey", "refreshToken", "secret", "token"]);
14
- const clampLimit = (limit) => Math.min(Math.max(Math.trunc(limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT);
15
- const clampOffset = (offset) => Math.max(0, Math.trunc(offset ?? 0));
16
- const normalizeRow = (row) => {
17
- const out = {};
18
- for (const [key, value] of Object.entries(row)) {
19
- if (SENSITIVE_FIELDS.has(key)) {
20
- continue;
21
- }
22
- out[key] = value instanceof Date ? value.getTime() : value;
23
- }
24
- return out;
25
- };
26
- const serializeRole = (role) => Array.isArray(role) ? role.join(",") : role;
27
- const asAdminError = (error) => {
28
- if (error instanceof LunoraAuthAdminError) {
29
- return error;
30
- }
31
- const candidate = error;
32
- const code = candidate?.body?.code ?? candidate?.code ?? "AUTH_ADMIN_ERROR";
33
- const message = candidate?.body?.message ?? candidate?.message ?? "auth admin operation failed";
34
- return new LunoraAuthAdminError(message, code);
35
- };
36
- const createAuthAdmin = (auth, options = {}) => {
37
- const context = auth.$context;
38
- const features = options.features ?? {};
39
- const withContext = async (function_) => {
40
- try {
41
- return await function_(await context);
42
- } catch (error) {
43
- throw asAdminError(error);
44
- }
45
- };
46
- const toUser = (row) => normalizeRow(row);
47
- const page = async (context_, model, options_) => {
48
- const where = options_.where && options_.where.length > 0 ? options_.where : void 0;
49
- const [rows, total] = await Promise.all([
50
- context_.adapter.findMany({
51
- limit: clampLimit(options_.limit),
52
- model,
53
- offset: clampOffset(options_.offset),
54
- sortBy: options_.sortBy,
55
- where
56
- }),
57
- context_.adapter.count({ model, where })
58
- ]);
59
- return { rows: rows.map((row) => normalizeRow(row)), total };
60
- };
61
- return {
62
- banUser: ({ expiresInSeconds, reason, userId }) => withContext(async (context_) => {
63
- const seconds = typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) ? Math.min(Math.trunc(expiresInSeconds), MAX_BAN_SECONDS) : 0;
64
- const banExpires = seconds > 0 ? new Date(Date.now() + seconds * 1e3) : void 0;
65
- const user = await context_.internalAdapter.updateUser(userId, {
66
- banExpires,
67
- banned: true,
68
- banReason: reason ?? "No reason"
69
- });
70
- await context_.internalAdapter.deleteUserSessions(userId);
71
- return toUser(user);
72
- }),
73
- cancelInvitation: ({ invitationId }) => withContext(async (context_) => {
74
- await context_.adapter.delete({ model: "invitation", where: [{ field: "id", value: invitationId }] });
75
- }),
76
- capabilities: () => withContext((context_) => {
77
- const ids = new Set((context_.options.plugins ?? []).map((plugin) => plugin.id));
78
- const has = (id) => ids.has(id);
79
- return Promise.resolve({
80
- accounts: features.accounts ?? true,
81
- admin: features.admin ?? has("admin"),
82
- organization: features.organization ?? has("organization"),
83
- passkey: features.passkey ?? has("passkey"),
84
- twoFactor: features.twoFactor ?? has("two-factor")
85
- });
86
- }),
87
- // The one op that genuinely builds a row rather than mutating one. Replicates
88
- // the plugin's create-user handler over `internalAdapter` (lowercase + dedupe
89
- // email, create the row, then link a credential account when a password is given).
90
- createUser: ({ data, email, name, password, role }) => withContext(async (context_) => {
91
- const normalizedEmail = email.toLowerCase();
92
- if (await context_.internalAdapter.findUserByEmail(normalizedEmail)) {
93
- throw new LunoraAuthAdminError("a user with this email already exists", "USER_ALREADY_EXISTS");
94
- }
95
- const user = await context_.internalAdapter.createUser({
96
- email: normalizedEmail,
97
- name,
98
- role: role === void 0 ? void 0 : serializeRole(role),
99
- ...data
100
- });
101
- if (password !== void 0 && password !== "") {
102
- const hashed = await context_.password.hash(password);
103
- await context_.internalAdapter.linkAccount({
104
- accountId: user.id,
105
- password: hashed,
106
- providerId: "credential",
107
- userId: user.id
108
- });
109
- }
110
- return toUser(user);
111
- }),
112
- deletePasskey: ({ passkeyId }) => withContext(async (context_) => {
113
- await context_.adapter.delete({ model: "passkey", where: [{ field: "id", value: passkeyId }] });
114
- }),
115
- disableTwoFactor: ({ userId }) => withContext(async (context_) => {
116
- await context_.adapter.deleteMany({ model: "twoFactor", where: [{ field: "userId", value: userId }] });
117
- await context_.internalAdapter.updateUser(userId, { twoFactorEnabled: false });
118
- }),
119
- impersonateUser: ({ userId }) => withContext(async (context_) => {
120
- const user = await context_.internalAdapter.findUserById(userId);
121
- if (!user) {
122
- throw new LunoraAuthAdminError("user not found", "USER_NOT_FOUND");
123
- }
124
- const rawSeconds = options.impersonationSeconds;
125
- let ttlSeconds = DEFAULT_IMPERSONATION_SECONDS;
126
- if (rawSeconds !== void 0) {
127
- if (!Number.isInteger(rawSeconds) || !Number.isFinite(rawSeconds) || rawSeconds <= 0) {
128
- throw new LunoraAuthAdminError("impersonationSeconds must be a positive finite integer", "INVALID_IMPERSONATION_SECONDS");
129
- }
130
- ttlSeconds = Math.min(rawSeconds, MAX_IMPERSONATION_SECONDS);
131
- }
132
- const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
133
- const session = await context_.internalAdapter.createSession(
134
- userId,
135
- true,
136
- { expiresAt, impersonatedBy: options.impersonatedBy ?? userId },
137
- true
138
- );
139
- return {
140
- expiresAt: session.expiresAt instanceof Date ? session.expiresAt.getTime() : expiresAt.getTime(),
141
- token: session.token,
142
- user: toUser(user)
143
- };
144
- }),
145
- listAccounts: ({ userId }) => withContext(async (context_) => {
146
- const rows = await context_.adapter.findMany({ model: "account", where: [{ field: "userId", value: userId }] });
147
- return rows.map((row) => normalizeRow(row));
148
- }),
149
- listInvitations: ({ limit, offset, organizationId }) => withContext(
150
- (context_) => page(context_, "invitation", {
151
- limit,
152
- offset,
153
- where: [{ field: "organizationId", value: organizationId }]
154
- })
155
- ),
156
- listMembers: ({ limit, offset, organizationId }) => withContext(
157
- (context_) => page(context_, "member", {
158
- limit,
159
- offset,
160
- sortBy: { direction: "desc", field: "createdAt" },
161
- where: [{ field: "organizationId", value: organizationId }]
162
- })
163
- ),
164
- listOrganizations: ({ limit, offset }) => withContext((context_) => page(context_, "organization", { limit, offset, sortBy: { direction: "desc", field: "createdAt" } })),
165
- listPasskeys: ({ userId }) => withContext(async (context_) => {
166
- const rows = await context_.adapter.findMany({ model: "passkey", where: [{ field: "userId", value: userId }] });
167
- return rows.map((row) => normalizeRow(row));
168
- }),
169
- listSessions: ({ limit, offset, userId }) => withContext(
170
- (context_) => page(context_, "session", {
171
- limit,
172
- offset,
173
- sortBy: { direction: "desc", field: "createdAt" },
174
- where: userId === void 0 || userId === "" ? void 0 : [{ field: "userId", value: userId }]
175
- })
176
- ),
177
- listUsers: ({ filterField, filterValue, limit, offset, search, searchField, sortBy, sortDirection }) => withContext((context_) => {
178
- const where = [];
179
- if (search !== void 0 && search !== "") {
180
- where.push({ field: searchField ?? "email", operator: "contains", value: search });
181
- }
182
- if (filterValue !== void 0) {
183
- where.push({ field: filterField ?? "email", operator: "eq", value: filterValue });
184
- }
185
- return page(context_, "user", {
186
- limit,
187
- offset,
188
- sortBy: { direction: sortDirection ?? "desc", field: sortBy ?? "createdAt" },
189
- where
190
- });
191
- }),
192
- removeMember: ({ memberId }) => withContext(async (context_) => {
193
- await context_.adapter.delete({ model: "member", where: [{ field: "id", value: memberId }] });
194
- }),
195
- removeUser: ({ userId }) => withContext(async (context_) => {
196
- await context_.internalAdapter.deleteUserSessions(userId);
197
- await context_.internalAdapter.deleteUser(userId);
198
- }),
199
- // Keyed on the session *id*, not its token: tokens are bearer credentials we
200
- // deliberately never surface to the studio. Resolve the row to recover its
201
- // token, then delete via `internalAdapter.deleteSession` — which also clears
202
- // secondary (KV) storage, unlike a raw `adapter.delete` on the DB row.
203
- revokeUserSession: ({ sessionId }) => withContext(async (context_) => {
204
- const session = await context_.adapter.findOne({ model: "session", where: [{ field: "id", value: sessionId }] });
205
- if (session?.token) {
206
- await context_.internalAdapter.deleteSession(session.token);
207
- }
208
- }),
209
- revokeUserSessions: ({ userId }) => withContext(async (context_) => {
210
- await context_.internalAdapter.deleteUserSessions(userId);
211
- }),
212
- setRole: ({ role, userId }) => withContext(async (context_) => {
213
- const user = await context_.internalAdapter.updateUser(userId, { role: serializeRole(role) });
214
- return toUser(user);
215
- }),
216
- setUserPassword: ({ newPassword, userId }) => withContext(async (context_) => {
217
- const min = context_.password.config.minPasswordLength;
218
- const max = context_.password.config.maxPasswordLength;
219
- if (newPassword.length < min) {
220
- throw new LunoraAuthAdminError(`password must be at least ${min.toString()} characters`, "PASSWORD_TOO_SHORT");
221
- }
222
- if (newPassword.length > max) {
223
- throw new LunoraAuthAdminError(`password must be at most ${max.toString()} characters`, "PASSWORD_TOO_LONG");
224
- }
225
- const hashed = await context_.password.hash(newPassword);
226
- await context_.internalAdapter.updatePassword(userId, hashed);
227
- }),
228
- unbanUser: ({ userId }) => withContext(async (context_) => {
229
- const user = await context_.internalAdapter.updateUser(userId, { banExpires: null, banned: false, banReason: null });
230
- return toUser(user);
231
- }),
232
- unlinkAccount: ({ accountId, userId }) => withContext(async (context_) => {
233
- await context_.adapter.delete({
234
- model: "account",
235
- where: [
236
- { field: "id", value: accountId },
237
- { connector: "AND", field: "userId", value: userId }
238
- ]
239
- });
240
- }),
241
- updateUser: ({ data, userId }) => withContext(async (context_) => {
242
- const user = await context_.internalAdapter.updateUser(userId, data);
243
- return toUser(user);
244
- })
245
- };
246
- };
247
-
248
- export { LunoraAuthAdminError, createAuthAdmin };