@wopr-network/platform-core 1.0.3 → 1.0.5

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/.env.example ADDED
@@ -0,0 +1,5 @@
1
+ # Platform UI URL (used for redirect allowlist)
2
+ # PLATFORM_UI_URL=https://platform.example.com
3
+
4
+ # Extra allowed redirect origins (comma-separated, for staging/preview environments)
5
+ # EXTRA_ALLOWED_REDIRECT_ORIGINS=https://staging.wopr.bot,https://preview.wopr.bot
@@ -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,6 +25,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
24
25
  let _config = null;
25
26
  let _userCreator = null;
26
27
  let _userCreatorPromise = null;
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;
27
31
  export async function getUserCreator() {
28
32
  if (_userCreator)
29
33
  return _userCreator;
@@ -65,8 +69,10 @@ function authOptions(cfg) {
65
69
  if (process.env.NODE_ENV === "production") {
66
70
  throw new Error("BETTER_AUTH_SECRET is required in production");
67
71
  }
68
- logger.warn("BetterAuth secret not configured — sessions may be insecure");
72
+ logger.warn("BetterAuth secret not configured — sessions will be invalidated on restart");
69
73
  }
74
+ _ephemeralSecret ??= randomBytes(32).toString("hex");
75
+ const effectiveSecret = secret || _ephemeralSecret;
70
76
  const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
71
77
  const basePath = cfg.basePath || "/api/auth";
72
78
  const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
@@ -81,7 +87,7 @@ function authOptions(cfg) {
81
87
  : { enabled: true, minPasswordLength: 12 };
82
88
  return {
83
89
  database: pool,
84
- secret: secret || "",
90
+ secret: effectiveSecret,
85
91
  baseURL,
86
92
  basePath,
87
93
  socialProviders: resolveSocialProviders(cfg),
@@ -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";
@@ -1,10 +1,29 @@
1
1
  const STATIC_ORIGINS = ["https://app.wopr.bot", "https://wopr.network"];
2
+ function parseExtraOrigins() {
3
+ const raw = process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
4
+ if (!raw)
5
+ return [];
6
+ return raw
7
+ .split(",")
8
+ .map((s) => s.trim())
9
+ .filter(Boolean)
10
+ .filter((entry) => {
11
+ try {
12
+ new URL(entry);
13
+ return true;
14
+ }
15
+ catch {
16
+ console.warn(`[redirect-allowlist] Malformed entry in EXTRA_ALLOWED_REDIRECT_ORIGINS, skipping: ${entry}`);
17
+ return false;
18
+ }
19
+ });
20
+ }
2
21
  function getAllowedOrigins() {
3
22
  return [
4
23
  ...STATIC_ORIGINS,
5
24
  ...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000", "http://localhost:3001"] : []),
6
25
  ...(process.env.PLATFORM_UI_URL ? [process.env.PLATFORM_UI_URL] : []),
7
- ...(process.env.NODE_ENV !== "production" ? ["https://example.com"] : []),
26
+ ...(process.env.NODE_ENV !== "production" ? parseExtraOrigins() : []),
8
27
  ];
9
28
  }
10
29
  /**
@@ -34,6 +34,40 @@ describe("assertSafeRedirectUrl", () => {
34
34
  it("rejects empty string", () => {
35
35
  expect(() => assertSafeRedirectUrl("")).toThrow("Invalid redirect URL");
36
36
  });
37
+ it("rejects https://example.com", () => {
38
+ expect(() => assertSafeRedirectUrl("https://example.com/callback")).toThrow("Invalid redirect URL");
39
+ });
40
+ describe("EXTRA_ALLOWED_REDIRECT_ORIGINS env-driven entries", () => {
41
+ beforeEach(() => {
42
+ vi.resetModules();
43
+ });
44
+ afterEach(() => {
45
+ delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
46
+ vi.resetModules();
47
+ });
48
+ it("allows origins listed in EXTRA_ALLOWED_REDIRECT_ORIGINS", async () => {
49
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot";
50
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
51
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
52
+ });
53
+ it("allows multiple comma-separated origins", async () => {
54
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot,https://preview.wopr.bot";
55
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
56
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
57
+ expect(() => assertSafe("https://preview.wopr.bot/dashboard")).not.toThrow();
58
+ });
59
+ it("ignores empty/whitespace entries in comma-separated list", async () => {
60
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot, , ,";
61
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
62
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
63
+ expect(() => assertSafe("https://evil.com/phishing")).toThrow("Invalid redirect URL");
64
+ });
65
+ it("defaults to empty when env var is unset", async () => {
66
+ delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
67
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
68
+ expect(() => assertSafe("https://random.example.org")).toThrow("Invalid redirect URL");
69
+ });
70
+ });
37
71
  describe("PLATFORM_UI_URL env-driven entry", () => {
38
72
  beforeEach(() => {
39
73
  process.env.PLATFORM_UI_URL = "https://platform.example.com";
@@ -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.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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,6 +95,10 @@ let _config: BetterAuthConfig | null = null;
94
95
  let _userCreator: IUserCreator | null = null;
95
96
  let _userCreatorPromise: Promise<IUserCreator> | null = null;
96
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
+
97
102
  export async function getUserCreator(): Promise<IUserCreator> {
98
103
  if (_userCreator) return _userCreator;
99
104
  if (!_userCreatorPromise) {
@@ -134,8 +139,10 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
134
139
  if (process.env.NODE_ENV === "production") {
135
140
  throw new Error("BETTER_AUTH_SECRET is required in production");
136
141
  }
137
- logger.warn("BetterAuth secret not configured — sessions may be insecure");
142
+ logger.warn("BetterAuth secret not configured — sessions will be invalidated on restart");
138
143
  }
144
+ _ephemeralSecret ??= randomBytes(32).toString("hex");
145
+ const effectiveSecret = secret || _ephemeralSecret;
139
146
  const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
140
147
  const basePath = cfg.basePath || "/api/auth";
141
148
  const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
@@ -152,7 +159,7 @@ function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
152
159
 
153
160
  return {
154
161
  database: pool,
155
- secret: secret || "",
162
+ secret: effectiveSecret,
156
163
  baseURL,
157
164
  basePath,
158
165
  socialProviders: resolveSocialProviders(cfg),
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";
@@ -46,6 +46,47 @@ describe("assertSafeRedirectUrl", () => {
46
46
  expect(() => assertSafeRedirectUrl("")).toThrow("Invalid redirect URL");
47
47
  });
48
48
 
49
+ it("rejects https://example.com", () => {
50
+ expect(() => assertSafeRedirectUrl("https://example.com/callback")).toThrow("Invalid redirect URL");
51
+ });
52
+
53
+ describe("EXTRA_ALLOWED_REDIRECT_ORIGINS env-driven entries", () => {
54
+ beforeEach(() => {
55
+ vi.resetModules();
56
+ });
57
+
58
+ afterEach(() => {
59
+ delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
60
+ vi.resetModules();
61
+ });
62
+
63
+ it("allows origins listed in EXTRA_ALLOWED_REDIRECT_ORIGINS", async () => {
64
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot";
65
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
66
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
67
+ });
68
+
69
+ it("allows multiple comma-separated origins", async () => {
70
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot,https://preview.wopr.bot";
71
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
72
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
73
+ expect(() => assertSafe("https://preview.wopr.bot/dashboard")).not.toThrow();
74
+ });
75
+
76
+ it("ignores empty/whitespace entries in comma-separated list", async () => {
77
+ process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot, , ,";
78
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
79
+ expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
80
+ expect(() => assertSafe("https://evil.com/phishing")).toThrow("Invalid redirect URL");
81
+ });
82
+
83
+ it("defaults to empty when env var is unset", async () => {
84
+ delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
85
+ const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
86
+ expect(() => assertSafe("https://random.example.org")).toThrow("Invalid redirect URL");
87
+ });
88
+ });
89
+
49
90
  describe("PLATFORM_UI_URL env-driven entry", () => {
50
91
  beforeEach(() => {
51
92
  process.env.PLATFORM_UI_URL = "https://platform.example.com";
@@ -1,11 +1,29 @@
1
1
  const STATIC_ORIGINS: string[] = ["https://app.wopr.bot", "https://wopr.network"];
2
2
 
3
+ function parseExtraOrigins(): string[] {
4
+ const raw = process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
5
+ if (!raw) return [];
6
+ return raw
7
+ .split(",")
8
+ .map((s) => s.trim())
9
+ .filter(Boolean)
10
+ .filter((entry) => {
11
+ try {
12
+ new URL(entry);
13
+ return true;
14
+ } catch {
15
+ console.warn(`[redirect-allowlist] Malformed entry in EXTRA_ALLOWED_REDIRECT_ORIGINS, skipping: ${entry}`);
16
+ return false;
17
+ }
18
+ });
19
+ }
20
+
3
21
  function getAllowedOrigins(): string[] {
4
22
  return [
5
23
  ...STATIC_ORIGINS,
6
24
  ...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000", "http://localhost:3001"] : []),
7
25
  ...(process.env.PLATFORM_UI_URL ? [process.env.PLATFORM_UI_URL] : []),
8
- ...(process.env.NODE_ENV !== "production" ? ["https://example.com"] : []),
26
+ ...(process.env.NODE_ENV !== "production" ? parseExtraOrigins() : []),
9
27
  ];
10
28
  }
11
29
 
@@ -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,