@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.
@@ -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(). */
@@ -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
- async function getUserCreator() {
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)).then((creator) => {
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 may be insecure");
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: 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
+ });
@@ -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";
@@ -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";
@@ -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
@@ -5,4 +5,5 @@ export function createDb(pool) {
5
5
  return drizzle(pool, { schema });
6
6
  }
7
7
  export { schema };
8
+ export { BetterAuthUserRepository } from "./auth-user-repository.js";
8
9
  export { creditColumn } from "./credit-column.js";
@@ -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
- id: m.id,
195
- userId: m.userId,
196
- name: m.userId, // name resolved from user context (placeholder until user profile lookup wired)
197
- email: "", // email resolved from user context (placeholder)
198
- role: m.role,
199
- joinedAt: new Date(m.joinedAt).toISOString(),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
+ });
@@ -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
- async function getUserCreator(): Promise<IUserCreator> {
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)).then((creator) => {
102
- _userCreator = creator;
103
- return creator;
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 may be insecure");
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: secret || "",
162
+ secret: effectiveSecret,
151
163
  baseURL,
152
164
  basePath,
153
165
  socialProviders: resolveSocialProviders(cfg),
package/src/auth/index.ts CHANGED
@@ -535,6 +535,7 @@ export type {
535
535
  export {
536
536
  getAuth,
537
537
  getEmailVerifier,
538
+ getUserCreator,
538
539
  initBetterAuth,
539
540
  resetAuth,
540
541
  resetUserCreator,
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
- id: m.id,
261
- userId: m.userId,
262
- name: m.userId, // name resolved from user context (placeholder until user profile lookup wired)
263
- email: "", // email resolved from user context (placeholder)
264
- role: m.role,
265
- joinedAt: new Date(m.joinedAt).toISOString(),
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,
package/vitest.config.ts CHANGED
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
3
3
  export default defineConfig({
4
4
  test: {
5
5
  testTimeout: 30000,
6
+ hookTimeout: 30000,
6
7
  include: ["src/**/*.test.ts"],
7
8
  coverage: {
8
9
  provider: "v8",