@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 +5 -0
- package/dist/auth/better-auth.js +8 -2
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.js +1 -0
- package/dist/security/redirect-allowlist.js +20 -1
- package/dist/security/redirect-allowlist.test.js +34 -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.ts +9 -2
- package/src/db/index.ts +2 -0
- package/src/security/redirect-allowlist.test.ts +41 -0
- package/src/security/redirect-allowlist.ts +19 -1
- package/src/tenancy/org-service.test.ts +84 -0
- package/src/tenancy/org-service.ts +31 -8
package/.env.example
ADDED
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,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
|
|
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:
|
|
90
|
+
secret: effectiveSecret,
|
|
85
91
|
baseURL,
|
|
86
92
|
basePath,
|
|
87
93
|
socialProviders: resolveSocialProviders(cfg),
|
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
|
@@ -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" ?
|
|
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
|
-
|
|
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
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,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
|
|
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:
|
|
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" ?
|
|
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
|
-
|
|
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,
|