@wopr-network/platform-core 1.0.2 → 1.0.4
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/auth/better-auth.d.ts +2 -0
- package/dist/auth/better-auth.js +15 -4
- package/dist/auth/better-auth.test.d.ts +1 -0
- package/dist/auth/better-auth.test.js +35 -0
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +1 -0
- package/dist/tenancy/org-service.d.ts +4 -0
- package/dist/tenancy/org-service.js +26 -8
- package/dist/tenancy/org-service.test.js +66 -0
- package/package.json +1 -1
- package/src/auth/better-auth.test.ts +47 -0
- package/src/auth/better-auth.ts +19 -7
- package/src/auth/index.ts +1 -0
- package/src/db/index.ts +2 -0
- package/src/tenancy/org-service.test.ts +84 -0
- package/src/tenancy/org-service.ts +31 -8
- package/vitest.config.ts +1 -0
|
@@ -11,6 +11,7 @@ import { betterAuth } from "better-auth";
|
|
|
11
11
|
import type { Pool } from "pg";
|
|
12
12
|
import type { PlatformDb } from "../db/index.js";
|
|
13
13
|
import { PgEmailVerifier } from "../email/verification.js";
|
|
14
|
+
import { type IUserCreator } from "./user-creator.js";
|
|
14
15
|
/** OAuth provider credentials. */
|
|
15
16
|
export interface OAuthProvider {
|
|
16
17
|
clientId: string;
|
|
@@ -63,6 +64,7 @@ export interface BetterAuthConfig {
|
|
|
63
64
|
/** Called after a new user signs up (e.g., create personal tenant). */
|
|
64
65
|
onUserCreated?: (userId: string, userName: string, email: string) => Promise<void>;
|
|
65
66
|
}
|
|
67
|
+
export declare function getUserCreator(): Promise<IUserCreator>;
|
|
66
68
|
/** The type of a better-auth instance. */
|
|
67
69
|
export type Auth = ReturnType<typeof betterAuth>;
|
|
68
70
|
/** Initialize Better Auth with the given config. Must be called before getAuth(). */
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* The auth instance is lazily initialized to avoid opening the database
|
|
8
8
|
* at module import time (which breaks tests).
|
|
9
9
|
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
10
11
|
import { betterAuth } from "better-auth";
|
|
11
12
|
import { twoFactor } from "better-auth/plugins";
|
|
12
13
|
import { RoleStore } from "../admin/role-store.js";
|
|
@@ -24,15 +25,23 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
24
25
|
let _config = null;
|
|
25
26
|
let _userCreator = null;
|
|
26
27
|
let _userCreatorPromise = null;
|
|
27
|
-
|
|
28
|
+
// Ephemeral secret: generated once per process, reused across authOptions() calls.
|
|
29
|
+
// Hoisted to module scope so resetAuth() (which nulls _auth) does not invalidate sessions.
|
|
30
|
+
let _ephemeralSecret = null;
|
|
31
|
+
export async function getUserCreator() {
|
|
28
32
|
if (_userCreator)
|
|
29
33
|
return _userCreator;
|
|
30
34
|
if (!_userCreatorPromise) {
|
|
31
35
|
if (!_config)
|
|
32
36
|
throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
33
|
-
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
37
|
+
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
38
|
+
.then((creator) => {
|
|
34
39
|
_userCreator = creator;
|
|
35
40
|
return creator;
|
|
41
|
+
})
|
|
42
|
+
.catch((err) => {
|
|
43
|
+
_userCreatorPromise = null;
|
|
44
|
+
throw err;
|
|
36
45
|
});
|
|
37
46
|
}
|
|
38
47
|
return _userCreatorPromise;
|
|
@@ -60,8 +69,10 @@ function authOptions(cfg) {
|
|
|
60
69
|
if (process.env.NODE_ENV === "production") {
|
|
61
70
|
throw new Error("BETTER_AUTH_SECRET is required in production");
|
|
62
71
|
}
|
|
63
|
-
logger.warn("BetterAuth secret not configured — sessions
|
|
72
|
+
logger.warn("BetterAuth secret not configured — sessions will be invalidated on restart");
|
|
64
73
|
}
|
|
74
|
+
_ephemeralSecret ??= randomBytes(32).toString("hex");
|
|
75
|
+
const effectiveSecret = secret || _ephemeralSecret;
|
|
65
76
|
const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
|
|
66
77
|
const basePath = cfg.basePath || "/api/auth";
|
|
67
78
|
const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
|
|
@@ -76,7 +87,7 @@ function authOptions(cfg) {
|
|
|
76
87
|
: { enabled: true, minPasswordLength: 12 };
|
|
77
88
|
return {
|
|
78
89
|
database: pool,
|
|
79
|
-
secret:
|
|
90
|
+
secret: effectiveSecret,
|
|
80
91
|
baseURL,
|
|
81
92
|
basePath,
|
|
82
93
|
socialProviders: resolveSocialProviders(cfg),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
const mockCreateUserCreator = vi.fn();
|
|
3
|
+
vi.mock("./user-creator.js", () => ({
|
|
4
|
+
createUserCreator: (...args) => mockCreateUserCreator(...args),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock("../admin/role-store.js", () => ({
|
|
7
|
+
RoleStore: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
|
|
10
|
+
const fakeConfig = { pool: {}, db: {} };
|
|
11
|
+
describe("getUserCreator", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
resetUserCreator();
|
|
14
|
+
mockCreateUserCreator.mockReset();
|
|
15
|
+
});
|
|
16
|
+
it("caches the resolved creator on success", async () => {
|
|
17
|
+
initBetterAuth(fakeConfig);
|
|
18
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
19
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
20
|
+
const first = await getUserCreator();
|
|
21
|
+
const second = await getUserCreator();
|
|
22
|
+
expect(first).toBe(second);
|
|
23
|
+
expect(mockCreateUserCreator).toHaveBeenCalledOnce();
|
|
24
|
+
});
|
|
25
|
+
it("clears cached promise on rejection so next call retries", async () => {
|
|
26
|
+
initBetterAuth(fakeConfig);
|
|
27
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
28
|
+
mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
|
|
29
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
30
|
+
await expect(getUserCreator()).rejects.toThrow("DB unavailable");
|
|
31
|
+
const creator = await getUserCreator();
|
|
32
|
+
expect(creator).toBe(fakeCreator);
|
|
33
|
+
expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -187,7 +187,7 @@ export declare function validateTenantAccess(userId: string, requestedTenantId:
|
|
|
187
187
|
export type { IApiKeyRepository } from "./api-key-repository.js";
|
|
188
188
|
export { DrizzleApiKeyRepository } from "./api-key-repository.js";
|
|
189
189
|
export type { Auth, AuthRateLimitRule, BetterAuthConfig, OAuthProvider, } from "./better-auth.js";
|
|
190
|
-
export { getAuth, getEmailVerifier, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
190
|
+
export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
191
191
|
export type { ILoginHistoryRepository, LoginHistoryEntry } from "./login-history-repository.js";
|
|
192
192
|
export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
|
|
193
193
|
export type { SessionAuthEnv } from "./middleware.js";
|
package/dist/auth/index.js
CHANGED
|
@@ -422,7 +422,7 @@ export async function validateTenantAccess(userId, requestedTenantId, orgMemberR
|
|
|
422
422
|
}
|
|
423
423
|
export { DrizzleApiKeyRepository } from "./api-key-repository.js";
|
|
424
424
|
// Test utilities — do not call in production code
|
|
425
|
-
export { getAuth, getEmailVerifier, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
425
|
+
export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
426
426
|
export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
|
|
427
427
|
export { dualAuth, sessionAuth } from "./middleware.js";
|
|
428
428
|
export { createUserCreator } from "./user-creator.js";
|
package/dist/db/index.d.ts
CHANGED
|
@@ -11,4 +11,6 @@ export type PlatformDb = PgDatabase<PgQueryResultHKT, PlatformSchema>;
|
|
|
11
11
|
/** Create a Drizzle database instance wrapping the given pg.Pool. */
|
|
12
12
|
export declare function createDb(pool: Pool): PlatformDb;
|
|
13
13
|
export { schema };
|
|
14
|
+
export type { AuthUser, IAuthUserRepository } from "./auth-user-repository.js";
|
|
15
|
+
export { BetterAuthUserRepository } from "./auth-user-repository.js";
|
|
14
16
|
export { creditColumn } from "./credit-column.js";
|
package/dist/db/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* role changes, and org CRUD. Depends on IOrgRepository (tenant data) and
|
|
4
4
|
* IOrgMemberRepository (members/invites data).
|
|
5
5
|
*/
|
|
6
|
+
import type { IAuthUserRepository } from "../db/auth-user-repository.js";
|
|
6
7
|
import type { PlatformDb } from "../db/index.js";
|
|
7
8
|
import type { IOrgRepository, Tenant } from "./drizzle-org-repository.js";
|
|
8
9
|
import type { IOrgMemberRepository, OrgInviteRow } from "./org-member-repository.js";
|
|
@@ -29,12 +30,15 @@ export interface OrgInvitePublic {
|
|
|
29
30
|
export interface OrgServiceOptions {
|
|
30
31
|
/** Hook called inside deleteOrg transaction before members/invites/tenant rows are deleted. */
|
|
31
32
|
onBeforeDeleteOrg?: (orgId: string, tx: PlatformDb) => Promise<void>;
|
|
33
|
+
/** Optional user profile resolver for populating member name/email. */
|
|
34
|
+
userRepo?: IAuthUserRepository;
|
|
32
35
|
}
|
|
33
36
|
export declare class OrgService {
|
|
34
37
|
private readonly orgRepo;
|
|
35
38
|
private readonly memberRepo;
|
|
36
39
|
private readonly db;
|
|
37
40
|
private readonly onBeforeDeleteOrg?;
|
|
41
|
+
private readonly userRepo?;
|
|
38
42
|
constructor(orgRepo: IOrgRepository, memberRepo: IOrgMemberRepository, db: PlatformDb, options?: OrgServiceOptions);
|
|
39
43
|
/**
|
|
40
44
|
* Return the personal org for the user, creating it if it doesn't exist.
|
|
@@ -13,11 +13,13 @@ export class OrgService {
|
|
|
13
13
|
memberRepo;
|
|
14
14
|
db;
|
|
15
15
|
onBeforeDeleteOrg;
|
|
16
|
+
userRepo;
|
|
16
17
|
constructor(orgRepo, memberRepo, db, options) {
|
|
17
18
|
this.orgRepo = orgRepo;
|
|
18
19
|
this.memberRepo = memberRepo;
|
|
19
20
|
this.db = db;
|
|
20
21
|
this.onBeforeDeleteOrg = options?.onBeforeDeleteOrg;
|
|
22
|
+
this.userRepo = options?.userRepo;
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
25
|
* Return the personal org for the user, creating it if it doesn't exist.
|
|
@@ -188,16 +190,32 @@ export class OrgService {
|
|
|
188
190
|
async buildOrgWithMembers(tenant) {
|
|
189
191
|
const members = await this.memberRepo.listMembers(tenant.id);
|
|
190
192
|
const invites = await this.memberRepo.listInvites(tenant.id);
|
|
193
|
+
// Batch-resolve user profiles when a userRepo is available.
|
|
194
|
+
let profileMap;
|
|
195
|
+
const userRepo = this.userRepo;
|
|
196
|
+
if (userRepo) {
|
|
197
|
+
const results = await Promise.allSettled(members.map((m) => userRepo.getUser(m.userId)));
|
|
198
|
+
profileMap = new Map();
|
|
199
|
+
for (let i = 0; i < members.length; i++) {
|
|
200
|
+
const result = results[i];
|
|
201
|
+
if (result.status === "fulfilled" && result.value) {
|
|
202
|
+
profileMap.set(members[i].userId, { name: result.value.name, email: result.value.email });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
191
206
|
return {
|
|
192
207
|
...tenant,
|
|
193
|
-
members: members.map((m) =>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
members: members.map((m) => {
|
|
209
|
+
const resolved = profileMap?.get(m.userId);
|
|
210
|
+
return {
|
|
211
|
+
id: m.id,
|
|
212
|
+
userId: m.userId,
|
|
213
|
+
name: resolved?.name ?? m.userId,
|
|
214
|
+
email: resolved?.email ?? "",
|
|
215
|
+
role: m.role,
|
|
216
|
+
joinedAt: new Date(m.joinedAt).toISOString(),
|
|
217
|
+
};
|
|
218
|
+
}),
|
|
201
219
|
invites: invites.map((i) => ({
|
|
202
220
|
id: i.id,
|
|
203
221
|
email: i.email,
|
|
@@ -547,4 +547,70 @@ describe("OrgService", () => {
|
|
|
547
547
|
await expect(svc.removeMember(org.id, owner, admin)).resolves.not.toThrow();
|
|
548
548
|
});
|
|
549
549
|
});
|
|
550
|
+
describe("buildOrgWithMembers member resolution", () => {
|
|
551
|
+
it("resolves member name and email from userRepo when provided", async () => {
|
|
552
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS "user" (id TEXT PRIMARY KEY, name TEXT, email TEXT, image TEXT, "twoFactorEnabled" BOOLEAN DEFAULT false)`);
|
|
553
|
+
await pool.query(`INSERT INTO "user" (id, name, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, email = $3`, ["resolve-user-1", "Alice Smith", "alice@example.com"]);
|
|
554
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
555
|
+
const userRepo = {
|
|
556
|
+
getUser: async (userId) => {
|
|
557
|
+
const { rows } = await pool.query(`SELECT id, name, email, image, "twoFactorEnabled" FROM "user" WHERE id = $1`, [userId]);
|
|
558
|
+
return rows[0] ?? null;
|
|
559
|
+
},
|
|
560
|
+
updateUser: vi.fn(),
|
|
561
|
+
changePassword: vi.fn(),
|
|
562
|
+
listAccounts: vi.fn(),
|
|
563
|
+
unlinkAccount: vi.fn(),
|
|
564
|
+
};
|
|
565
|
+
const svc = new OrgService(orgRepo, memberRepo, db, { userRepo });
|
|
566
|
+
const org = await orgRepo.createOrg("resolve-user-1", "Resolve Org", "resolve-org");
|
|
567
|
+
await memberRepo.addMember({
|
|
568
|
+
id: "m-resolve-1",
|
|
569
|
+
orgId: org.id,
|
|
570
|
+
userId: "resolve-user-1",
|
|
571
|
+
role: "owner",
|
|
572
|
+
joinedAt: Date.now(),
|
|
573
|
+
});
|
|
574
|
+
const result = await svc.getOrg(org.id);
|
|
575
|
+
expect(result.members[0].name).toBe("Alice Smith");
|
|
576
|
+
expect(result.members[0].email).toBe("alice@example.com");
|
|
577
|
+
});
|
|
578
|
+
it("falls back to userId/empty when userRepo returns null (deleted user)", async () => {
|
|
579
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
580
|
+
const userRepo = {
|
|
581
|
+
getUser: async () => null,
|
|
582
|
+
updateUser: vi.fn(),
|
|
583
|
+
changePassword: vi.fn(),
|
|
584
|
+
listAccounts: vi.fn(),
|
|
585
|
+
unlinkAccount: vi.fn(),
|
|
586
|
+
};
|
|
587
|
+
const svc = new OrgService(orgRepo, memberRepo, db, { userRepo });
|
|
588
|
+
const org = await orgRepo.createOrg("ghost-user", "Ghost Org", "ghost-org");
|
|
589
|
+
await memberRepo.addMember({
|
|
590
|
+
id: "m-ghost-1",
|
|
591
|
+
orgId: org.id,
|
|
592
|
+
userId: "ghost-user",
|
|
593
|
+
role: "owner",
|
|
594
|
+
joinedAt: Date.now(),
|
|
595
|
+
});
|
|
596
|
+
const result = await svc.getOrg(org.id);
|
|
597
|
+
expect(result.members[0].name).toBe("ghost-user");
|
|
598
|
+
expect(result.members[0].email).toBe("");
|
|
599
|
+
});
|
|
600
|
+
it("falls back to userId/empty when no userRepo is provided (backward compat)", async () => {
|
|
601
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
602
|
+
const svc = new OrgService(orgRepo, memberRepo, db); // no userRepo
|
|
603
|
+
const org = await orgRepo.createOrg("compat-user", "Compat Org", "compat-org");
|
|
604
|
+
await memberRepo.addMember({
|
|
605
|
+
id: "m-compat-1",
|
|
606
|
+
orgId: org.id,
|
|
607
|
+
userId: "compat-user",
|
|
608
|
+
role: "owner",
|
|
609
|
+
joinedAt: Date.now(),
|
|
610
|
+
});
|
|
611
|
+
const result = await svc.getOrg(org.id);
|
|
612
|
+
expect(result.members[0].name).toBe("compat-user");
|
|
613
|
+
expect(result.members[0].email).toBe("");
|
|
614
|
+
});
|
|
615
|
+
});
|
|
550
616
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockCreateUserCreator = vi.fn();
|
|
4
|
+
vi.mock("./user-creator.js", () => ({
|
|
5
|
+
createUserCreator: (...args: unknown[]) => mockCreateUserCreator(...args),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("../admin/role-store.js", () => ({
|
|
9
|
+
RoleStore: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
|
|
13
|
+
|
|
14
|
+
const fakeConfig = { pool: {}, db: {} } as Parameters<typeof initBetterAuth>[0];
|
|
15
|
+
|
|
16
|
+
describe("getUserCreator", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetUserCreator();
|
|
19
|
+
mockCreateUserCreator.mockReset();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("caches the resolved creator on success", async () => {
|
|
23
|
+
initBetterAuth(fakeConfig);
|
|
24
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
25
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
26
|
+
|
|
27
|
+
const first = await getUserCreator();
|
|
28
|
+
const second = await getUserCreator();
|
|
29
|
+
|
|
30
|
+
expect(first).toBe(second);
|
|
31
|
+
expect(mockCreateUserCreator).toHaveBeenCalledOnce();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("clears cached promise on rejection so next call retries", async () => {
|
|
35
|
+
initBetterAuth(fakeConfig);
|
|
36
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
37
|
+
|
|
38
|
+
mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
|
|
39
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
40
|
+
|
|
41
|
+
await expect(getUserCreator()).rejects.toThrow("DB unavailable");
|
|
42
|
+
|
|
43
|
+
const creator = await getUserCreator();
|
|
44
|
+
expect(creator).toBe(fakeCreator);
|
|
45
|
+
expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* at module import time (which breaks tests).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
11
12
|
import { type BetterAuthOptions, betterAuth } from "better-auth";
|
|
12
13
|
import { twoFactor } from "better-auth/plugins";
|
|
13
14
|
import type { Pool } from "pg";
|
|
@@ -94,14 +95,23 @@ let _config: BetterAuthConfig | null = null;
|
|
|
94
95
|
let _userCreator: IUserCreator | null = null;
|
|
95
96
|
let _userCreatorPromise: Promise<IUserCreator> | null = null;
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// Ephemeral secret: generated once per process, reused across authOptions() calls.
|
|
99
|
+
// Hoisted to module scope so resetAuth() (which nulls _auth) does not invalidate sessions.
|
|
100
|
+
let _ephemeralSecret: string | null = null;
|
|
101
|
+
|
|
102
|
+
export async function getUserCreator(): Promise<IUserCreator> {
|
|
98
103
|
if (_userCreator) return _userCreator;
|
|
99
104
|
if (!_userCreatorPromise) {
|
|
100
105
|
if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
101
|
-
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
107
|
+
.then((creator) => {
|
|
108
|
+
_userCreator = creator;
|
|
109
|
+
return creator;
|
|
110
|
+
})
|
|
111
|
+
.catch((err) => {
|
|
112
|
+
_userCreatorPromise = null;
|
|
113
|
+
throw err;
|
|
114
|
+
});
|
|
105
115
|
}
|
|
106
116
|
return _userCreatorPromise;
|
|
107
117
|
}
|
|
@@ -129,8 +139,10 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
|
|
|
129
139
|
if (process.env.NODE_ENV === "production") {
|
|
130
140
|
throw new Error("BETTER_AUTH_SECRET is required in production");
|
|
131
141
|
}
|
|
132
|
-
logger.warn("BetterAuth secret not configured — sessions
|
|
142
|
+
logger.warn("BetterAuth secret not configured — sessions will be invalidated on restart");
|
|
133
143
|
}
|
|
144
|
+
_ephemeralSecret ??= randomBytes(32).toString("hex");
|
|
145
|
+
const effectiveSecret = secret || _ephemeralSecret;
|
|
134
146
|
const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
|
|
135
147
|
const basePath = cfg.basePath || "/api/auth";
|
|
136
148
|
const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
|
|
@@ -147,7 +159,7 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
|
|
|
147
159
|
|
|
148
160
|
return {
|
|
149
161
|
database: pool,
|
|
150
|
-
secret:
|
|
162
|
+
secret: effectiveSecret,
|
|
151
163
|
baseURL,
|
|
152
164
|
basePath,
|
|
153
165
|
socialProviders: resolveSocialProviders(cfg),
|
package/src/auth/index.ts
CHANGED
package/src/db/index.ts
CHANGED
|
@@ -18,4 +18,6 @@ export function createDb(pool: Pool): PlatformDb {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export { schema };
|
|
21
|
+
export type { AuthUser, IAuthUserRepository } from "./auth-user-repository.js";
|
|
22
|
+
export { BetterAuthUserRepository } from "./auth-user-repository.js";
|
|
21
23
|
export { creditColumn } from "./credit-column.js";
|
|
@@ -631,4 +631,88 @@ describe("OrgService", () => {
|
|
|
631
631
|
await expect(svc.removeMember(org.id, owner, admin)).resolves.not.toThrow();
|
|
632
632
|
});
|
|
633
633
|
});
|
|
634
|
+
|
|
635
|
+
describe("buildOrgWithMembers member resolution", () => {
|
|
636
|
+
it("resolves member name and email from userRepo when provided", async () => {
|
|
637
|
+
await pool.query(
|
|
638
|
+
`CREATE TABLE IF NOT EXISTS "user" (id TEXT PRIMARY KEY, name TEXT, email TEXT, image TEXT, "twoFactorEnabled" BOOLEAN DEFAULT false)`,
|
|
639
|
+
);
|
|
640
|
+
await pool.query(
|
|
641
|
+
`INSERT INTO "user" (id, name, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, email = $3`,
|
|
642
|
+
["resolve-user-1", "Alice Smith", "alice@example.com"],
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
646
|
+
const userRepo = {
|
|
647
|
+
getUser: async (userId: string) => {
|
|
648
|
+
const { rows } = await pool.query<{
|
|
649
|
+
id: string;
|
|
650
|
+
name: string;
|
|
651
|
+
email: string;
|
|
652
|
+
image: string | null;
|
|
653
|
+
twoFactorEnabled: boolean;
|
|
654
|
+
}>(`SELECT id, name, email, image, "twoFactorEnabled" FROM "user" WHERE id = $1`, [userId]);
|
|
655
|
+
return rows[0] ?? null;
|
|
656
|
+
},
|
|
657
|
+
updateUser: vi.fn(),
|
|
658
|
+
changePassword: vi.fn(),
|
|
659
|
+
listAccounts: vi.fn(),
|
|
660
|
+
unlinkAccount: vi.fn(),
|
|
661
|
+
};
|
|
662
|
+
const svc = new OrgService(orgRepo, memberRepo, db, { userRepo });
|
|
663
|
+
const org = await orgRepo.createOrg("resolve-user-1", "Resolve Org", "resolve-org");
|
|
664
|
+
await memberRepo.addMember({
|
|
665
|
+
id: "m-resolve-1",
|
|
666
|
+
orgId: org.id,
|
|
667
|
+
userId: "resolve-user-1",
|
|
668
|
+
role: "owner",
|
|
669
|
+
joinedAt: Date.now(),
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const result = await svc.getOrg(org.id);
|
|
673
|
+
expect(result.members[0].name).toBe("Alice Smith");
|
|
674
|
+
expect(result.members[0].email).toBe("alice@example.com");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("falls back to userId/empty when userRepo returns null (deleted user)", async () => {
|
|
678
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
679
|
+
const userRepo = {
|
|
680
|
+
getUser: async () => null,
|
|
681
|
+
updateUser: vi.fn(),
|
|
682
|
+
changePassword: vi.fn(),
|
|
683
|
+
listAccounts: vi.fn(),
|
|
684
|
+
unlinkAccount: vi.fn(),
|
|
685
|
+
};
|
|
686
|
+
const svc = new OrgService(orgRepo, memberRepo, db, { userRepo });
|
|
687
|
+
const org = await orgRepo.createOrg("ghost-user", "Ghost Org", "ghost-org");
|
|
688
|
+
await memberRepo.addMember({
|
|
689
|
+
id: "m-ghost-1",
|
|
690
|
+
orgId: org.id,
|
|
691
|
+
userId: "ghost-user",
|
|
692
|
+
role: "owner",
|
|
693
|
+
joinedAt: Date.now(),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const result = await svc.getOrg(org.id);
|
|
697
|
+
expect(result.members[0].name).toBe("ghost-user");
|
|
698
|
+
expect(result.members[0].email).toBe("");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("falls back to userId/empty when no userRepo is provided (backward compat)", async () => {
|
|
702
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
703
|
+
const svc = new OrgService(orgRepo, memberRepo, db); // no userRepo
|
|
704
|
+
const org = await orgRepo.createOrg("compat-user", "Compat Org", "compat-org");
|
|
705
|
+
await memberRepo.addMember({
|
|
706
|
+
id: "m-compat-1",
|
|
707
|
+
orgId: org.id,
|
|
708
|
+
userId: "compat-user",
|
|
709
|
+
role: "owner",
|
|
710
|
+
joinedAt: Date.now(),
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const result = await svc.getOrg(org.id);
|
|
714
|
+
expect(result.members[0].name).toBe("compat-user");
|
|
715
|
+
expect(result.members[0].email).toBe("");
|
|
716
|
+
});
|
|
717
|
+
});
|
|
634
718
|
});
|
|
@@ -8,6 +8,7 @@ import crypto from "node:crypto";
|
|
|
8
8
|
import { TRPCError } from "@trpc/server";
|
|
9
9
|
import { eq } from "drizzle-orm";
|
|
10
10
|
import { logger } from "../config/logger.js";
|
|
11
|
+
import type { IAuthUserRepository } from "../db/auth-user-repository.js";
|
|
11
12
|
import type { PlatformDb } from "../db/index.js";
|
|
12
13
|
import { organizationInvites, organizationMembers, tenants } from "../db/schema/index.js";
|
|
13
14
|
import type { IOrgRepository, Tenant } from "./drizzle-org-repository.js";
|
|
@@ -47,10 +48,13 @@ export interface OrgInvitePublic {
|
|
|
47
48
|
export interface OrgServiceOptions {
|
|
48
49
|
/** Hook called inside deleteOrg transaction before members/invites/tenant rows are deleted. */
|
|
49
50
|
onBeforeDeleteOrg?: (orgId: string, tx: PlatformDb) => Promise<void>;
|
|
51
|
+
/** Optional user profile resolver for populating member name/email. */
|
|
52
|
+
userRepo?: IAuthUserRepository;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export class OrgService {
|
|
53
56
|
private readonly onBeforeDeleteOrg?: (orgId: string, tx: PlatformDb) => Promise<void>;
|
|
57
|
+
private readonly userRepo?: IAuthUserRepository;
|
|
54
58
|
|
|
55
59
|
constructor(
|
|
56
60
|
private readonly orgRepo: IOrgRepository,
|
|
@@ -59,6 +63,7 @@ export class OrgService {
|
|
|
59
63
|
options?: OrgServiceOptions,
|
|
60
64
|
) {
|
|
61
65
|
this.onBeforeDeleteOrg = options?.onBeforeDeleteOrg;
|
|
66
|
+
this.userRepo = options?.userRepo;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
@@ -254,16 +259,34 @@ export class OrgService {
|
|
|
254
259
|
private async buildOrgWithMembers(tenant: Tenant): Promise<OrgWithMembers> {
|
|
255
260
|
const members = await this.memberRepo.listMembers(tenant.id);
|
|
256
261
|
const invites = await this.memberRepo.listInvites(tenant.id);
|
|
262
|
+
|
|
263
|
+
// Batch-resolve user profiles when a userRepo is available.
|
|
264
|
+
let profileMap: Map<string, { name: string; email: string }> | undefined;
|
|
265
|
+
const userRepo = this.userRepo;
|
|
266
|
+
if (userRepo) {
|
|
267
|
+
const results = await Promise.allSettled(members.map((m) => userRepo.getUser(m.userId)));
|
|
268
|
+
profileMap = new Map();
|
|
269
|
+
for (let i = 0; i < members.length; i++) {
|
|
270
|
+
const result = results[i];
|
|
271
|
+
if (result.status === "fulfilled" && result.value) {
|
|
272
|
+
profileMap.set(members[i].userId, { name: result.value.name, email: result.value.email });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
return {
|
|
258
278
|
...tenant,
|
|
259
|
-
members: members.map((m) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
279
|
+
members: members.map((m) => {
|
|
280
|
+
const resolved = profileMap?.get(m.userId);
|
|
281
|
+
return {
|
|
282
|
+
id: m.id,
|
|
283
|
+
userId: m.userId,
|
|
284
|
+
name: resolved?.name ?? m.userId,
|
|
285
|
+
email: resolved?.email ?? "",
|
|
286
|
+
role: m.role,
|
|
287
|
+
joinedAt: new Date(m.joinedAt).toISOString(),
|
|
288
|
+
};
|
|
289
|
+
}),
|
|
267
290
|
invites: invites.map((i) => ({
|
|
268
291
|
id: i.id,
|
|
269
292
|
email: i.email,
|