@lunora/auth 1.0.0-alpha.2 → 1.0.0-alpha.21
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/LICENSE.md +6 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/adapter.mjs +1 -0
- package/dist/index.d.mts +199 -7
- package/dist/index.d.ts +199 -7
- package/dist/index.mjs +4 -4
- package/dist/middleware.d.mts +3 -2
- package/dist/middleware.d.ts +3 -2
- package/dist/middleware.mjs +6 -3
- package/dist/packem_shared/LunoraAuthAdminError-D4L7n6gN.mjs +510 -0
- package/dist/packem_shared/{compileMigrationsSql-wZH3oXDu.mjs → compileMigrationsSql-B9bj-mJv.mjs} +2 -1
- package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.mts +128 -0
- package/dist/packem_shared/create-auth.d-Mwhb4gSc.d.ts +128 -0
- package/dist/packem_shared/{createAuth-B-tvsvQU.mjs → createAuth-BVMMllTm.mjs} +39 -8
- package/dist/packem_shared/{sessionPresets-B95rXrd8.mjs → sessionPresets-Dwwd74_J.mjs} +3 -0
- package/dist/schema.d.mts +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/sql-store.mjs +22 -0
- package/dist/store.d.mts +18 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.mjs +13 -0
- package/package.json +7 -6
- package/dist/packem_shared/LunoraAuthAdminError-BxrfEeA_.mjs +0 -249
- package/dist/packem_shared/create-auth.d-M36jwG_Y.d.mts +0 -58
- package/dist/packem_shared/create-auth.d-M36jwG_Y.d.ts +0 -58
|
@@ -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/dist/packem_shared/{compileMigrationsSql-wZH3oXDu.mjs → compileMigrationsSql-B9bj-mJv.mjs}
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getMigrations } from 'better-auth/db/migration';
|
|
2
|
+
import { resolveAuthOptions } from './createAuth-BVMMllTm.mjs';
|
|
2
3
|
|
|
3
4
|
const migrating = /* @__PURE__ */ new WeakMap();
|
|
4
5
|
const ensureMigrated = async (auth) => {
|
|
@@ -21,7 +22,7 @@ const ensureMigrated = async (auth) => {
|
|
|
21
22
|
}
|
|
22
23
|
};
|
|
23
24
|
const compileMigrationsSql = async (options) => {
|
|
24
|
-
const { compileMigrations } = await getMigrations(options);
|
|
25
|
+
const { compileMigrations } = await getMigrations(resolveAuthOptions(options));
|
|
25
26
|
return compileMigrations();
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { betterAuth, BetterAuthOptions } from 'better-auth';
|
|
2
|
+
/**
|
|
3
|
+
* Lunora's options pass straight through to better-auth — the only thing we add
|
|
4
|
+
* is requiring `secret` up front so a misconfigured deployment fails loudly
|
|
5
|
+
* instead of at the first sign-in.
|
|
6
|
+
*
|
|
7
|
+
* For `database`, prefer `lunoraD1Adapter` (`database: lunoraD1Adapter(env.DB)`)
|
|
8
|
+
* over passing the raw `env.DB`. better-auth *does* accept a D1Database directly,
|
|
9
|
+
* but it then resolves its Kysely adapter via a runtime `await import(...)` inside
|
|
10
|
+
* `auth.$context` — and that import never settles under `@cloudflare/vite-plugin`'s
|
|
11
|
+
* worker runner, hanging every auth request in `pnpm dev`. The explicit adapter
|
|
12
|
+
* skips it, so dev and prod behave the same. (Raw `env.DB` is still correct for
|
|
13
|
+
* the migration-only instance — see `lunoraD1Adapter`'s note.)
|
|
14
|
+
*
|
|
15
|
+
* Session rotation / richer session policies are configured via the `session`
|
|
16
|
+
* field (a `SessionPolicy`); Lunora validates it for obviously-broken
|
|
17
|
+
* durations and forwards it verbatim to better-auth. See `sessionPresets`
|
|
18
|
+
* for ready-made rotation/expiry trade-offs.
|
|
19
|
+
*
|
|
20
|
+
* ## Serverless background tasks (Cloudflare Workers)
|
|
21
|
+
*
|
|
22
|
+
* better-auth runs some work *after* sending the response — most importantly the
|
|
23
|
+
* password-reset email, whose background send is what keeps reset responses
|
|
24
|
+
* constant-time (a timing-attack defence: the response doesn't reveal whether
|
|
25
|
+
* the account exists). On Cloudflare Workers a promise that isn't handed to
|
|
26
|
+
* `ctx.waitUntil` can be cancelled the moment the response returns, dropping
|
|
27
|
+
* that send and weakening the guarantee. Wire your request's `ctx.waitUntil`
|
|
28
|
+
* into better-auth's background handler so the work survives:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* // in your worker fetch handler, where `ctx: ExecutionContext` is in scope
|
|
32
|
+
* const auth = createAuth({
|
|
33
|
+
* secret: env.AUTH_SECRET,
|
|
34
|
+
* database: lunoraD1Adapter(env.DB),
|
|
35
|
+
* advanced: {
|
|
36
|
+
* backgroundTasks: { handler: (promise) => ctx.waitUntil(promise) },
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* (Lunora can't set this for you — `ctx.waitUntil` is per-request, but
|
|
42
|
+
* `createAuth` runs once at worker setup.)
|
|
43
|
+
*/
|
|
44
|
+
type LunoraAuthOptions = BetterAuthOptions;
|
|
45
|
+
/**
|
|
46
|
+
* The full better-auth instance: `auth.handler` accepts a `Request` and
|
|
47
|
+
* returns a `Response` (used by `handleAuthRequest`); `auth.api`
|
|
48
|
+
* exposes the typed endpoint surface for server-side calls (e.g.
|
|
49
|
+
* `auth.api.getSession({ headers })` inside a query/mutation).
|
|
50
|
+
*/
|
|
51
|
+
type LunoraAuth = ReturnType<typeof betterAuth>;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the caller's options into the exact shape `createAuth` hands to
|
|
54
|
+
* `betterAuth` — the hardened, default-filled options the running worker uses.
|
|
55
|
+
* Exported (and pure) so the migration path can compile the schema from the
|
|
56
|
+
* same resolved options: `compileMigrationsSql` routes through here, so the
|
|
57
|
+
* `rateLimit` table the worker's durable limiter writes to is included in the
|
|
58
|
+
* migration rather than silently omitted (it would be, if migrations saw the
|
|
59
|
+
* raw options while the worker ran the resolved ones).
|
|
60
|
+
*
|
|
61
|
+
* ## What it fills (each gated independently on caller silence)
|
|
62
|
+
*
|
|
63
|
+
* Secure-by-default cookies + secret-strength warning via {@link hardenAuthOptions},
|
|
64
|
+
* applied first so all hardening composes onto one options object.
|
|
65
|
+
*
|
|
66
|
+
* Rate limiting is ON by default for `/api/auth/*`.
|
|
67
|
+
*
|
|
68
|
+
* better-auth's own default is `rateLimit.enabled ?? isProduction`, and its
|
|
69
|
+
* `isProduction` is `"development" === "production"` resolved at
|
|
70
|
+
* module-load time. On Cloudflare Workers that check is unreliable: the
|
|
71
|
+
* runtime has no Node `process.env` (absent entirely without
|
|
72
|
+
* `nodejs_compat`, and even with it `NODE_ENV` is rarely `"production"` at
|
|
73
|
+
* request time). So better-auth would silently leave auth endpoints
|
|
74
|
+
* _unthrottled_ on a real deployment — the surprise we refuse to ship.
|
|
75
|
+
*
|
|
76
|
+
* We therefore default `enabled: true` whenever the caller hasn't made an
|
|
77
|
+
* explicit choice. We only fill the `enabled` flag and otherwise forward
|
|
78
|
+
* the caller's `rateLimit` verbatim, so better-auth's `window` (10s) / `max`
|
|
79
|
+
* (100) defaults and any custom rules still apply. Callers who genuinely
|
|
80
|
+
* want it off can pass `rateLimit: { enabled: false }` (e.g. when fronting
|
|
81
|
+
* auth with their own limiter), and any explicit `enabled` value wins.
|
|
82
|
+
*
|
|
83
|
+
* We also default `storage: "database"` — but only when rate limiting is not
|
|
84
|
+
* explicitly disabled (`enabled !== false`). Filling storage under a disabled
|
|
85
|
+
* limiter is harmless at runtime but makes `getAuthTables` emit an unused
|
|
86
|
+
* `rateLimit` table, so we skip it there.
|
|
87
|
+
*
|
|
88
|
+
* better-auth's own default is `storage: "memory"` — a per-isolate,
|
|
89
|
+
* non-durable counter. On Cloudflare Workers that means each isolate keeps
|
|
90
|
+
* its own tally, counters vanish on isolate recycle, and traffic spread
|
|
91
|
+
* across isolates never sums to the configured `max` — a limiter that
|
|
92
|
+
* reports "enabled" while never enforcing a global limit (the exact
|
|
93
|
+
* brute-force / credential-stuffing protection on `/sign-in`, OTP, and
|
|
94
|
+
* password-reset it is meant to buy). `storage: "database"` rides the counter
|
|
95
|
+
* through the configured `database` adapter — Lunora's store over the D1 auth
|
|
96
|
+
* tables — so the limit is durable *and* atomic (the store's native
|
|
97
|
+
* `incrementOne` gives a one-winner guarantee across isolates). Callers with
|
|
98
|
+
* their own durable store can pass an explicit `rateLimit: { storage: … }`
|
|
99
|
+
* (or `customStorage`), and any explicit value wins.
|
|
100
|
+
*
|
|
101
|
+
* Session cookie cache is ON by default too.
|
|
102
|
+
*
|
|
103
|
+
* Every authenticated call resolves identity through better-auth's
|
|
104
|
+
* `getSession`, which — without a cache — is a DB (D1) read on the hot path
|
|
105
|
+
* of every query/mutation/action that reads `ctx.auth` and of the WebSocket
|
|
106
|
+
* upgrade. better-auth's `session.cookieCache` carries the session payload in
|
|
107
|
+
* a short-lived signed cookie so `getSession` can answer without hitting the
|
|
108
|
+
* database until the cache window elapses. We default it on with a
|
|
109
|
+
* deliberately short 60s `maxAge` (better-auth's own default is 300s): long
|
|
110
|
+
* enough to erase the per-request read for a burst of calls, short enough
|
|
111
|
+
* that a revoked or role-changed session self-corrects within a minute.
|
|
112
|
+
* The one tradeoff — a revoked session stays valid until the cache expires —
|
|
113
|
+
* is bounded by that TTL; callers who need immediate revocation opt out with
|
|
114
|
+
* `session: { cookieCache: { enabled: false } }` (or the `strict` preset).
|
|
115
|
+
*
|
|
116
|
+
* Every explicit caller value is forwarded verbatim. The two `rateLimit` fills
|
|
117
|
+
* merge into a single `rateLimit` object so neither clobbers the other.
|
|
118
|
+
*/
|
|
119
|
+
declare const resolveAuthOptions: (options: LunoraAuthOptions) => LunoraAuthOptions;
|
|
120
|
+
/**
|
|
121
|
+
* Create the auth instance. Thin wrapper around `betterAuth` that enforces
|
|
122
|
+
* the `secret` requirement at construction time so misconfigured deployments
|
|
123
|
+
* fail loudly at the first fetch rather than the first sign-in attempt, then
|
|
124
|
+
* hands {@link resolveAuthOptions}'s hardened, default-filled options to
|
|
125
|
+
* better-auth.
|
|
126
|
+
*/
|
|
127
|
+
declare const createAuth: (options: LunoraAuthOptions) => LunoraAuth;
|
|
128
|
+
export { LunoraAuth as L, LunoraAuthOptions as a, createAuth as c, resolveAuthOptions as r };
|