@wopr-network/platform-core 1.16.0 → 1.17.0
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/tenant-access.test.js +2 -0
- package/dist/db/schema/organization-members.d.ts +34 -0
- package/dist/db/schema/organization-members.js +2 -0
- package/dist/tenancy/org-member-repository.d.ts +12 -0
- package/dist/tenancy/org-member-repository.js +14 -0
- package/dist/tenancy/org-service.d.ts +10 -1
- package/dist/tenancy/org-service.js +39 -0
- package/dist/tenancy/org-service.test.js +219 -0
- package/dist/trpc/index.d.ts +1 -1
- package/dist/trpc/index.js +1 -1
- package/dist/trpc/init.d.ts +7 -0
- package/dist/trpc/init.js +16 -1
- package/dist/trpc/init.test.js +39 -1
- package/drizzle/migrations/0006_invite_acceptance.sql +2 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/auth/tenant-access.test.ts +2 -0
- package/src/db/schema/organization-members.ts +2 -0
- package/src/tenancy/org-member-repository.ts +20 -0
- package/src/tenancy/org-service.test.ts +260 -0
- package/src/tenancy/org-service.ts +42 -1
- package/src/trpc/index.ts +1 -0
- package/src/trpc/init.test.ts +48 -0
- package/src/trpc/init.ts +18 -1
|
@@ -15,6 +15,8 @@ function mockOrgMemberRepo(overrides = {}) {
|
|
|
15
15
|
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
16
16
|
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
17
17
|
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
19
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
18
20
|
...overrides,
|
|
19
21
|
};
|
|
20
22
|
}
|
|
@@ -230,6 +230,40 @@ export declare const organizationInvites: import("drizzle-orm/pg-core").PgTableW
|
|
|
230
230
|
identity: undefined;
|
|
231
231
|
generated: undefined;
|
|
232
232
|
}, {}, {}>;
|
|
233
|
+
acceptedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
234
|
+
name: "accepted_at";
|
|
235
|
+
tableName: "organization_invites";
|
|
236
|
+
dataType: "number";
|
|
237
|
+
columnType: "PgBigInt53";
|
|
238
|
+
data: number;
|
|
239
|
+
driverParam: string | number;
|
|
240
|
+
notNull: false;
|
|
241
|
+
hasDefault: false;
|
|
242
|
+
isPrimaryKey: false;
|
|
243
|
+
isAutoincrement: false;
|
|
244
|
+
hasRuntimeDefault: false;
|
|
245
|
+
enumValues: undefined;
|
|
246
|
+
baseColumn: never;
|
|
247
|
+
identity: undefined;
|
|
248
|
+
generated: undefined;
|
|
249
|
+
}, {}, {}>;
|
|
250
|
+
revokedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
251
|
+
name: "revoked_at";
|
|
252
|
+
tableName: "organization_invites";
|
|
253
|
+
dataType: "number";
|
|
254
|
+
columnType: "PgBigInt53";
|
|
255
|
+
data: number;
|
|
256
|
+
driverParam: string | number;
|
|
257
|
+
notNull: false;
|
|
258
|
+
hasDefault: false;
|
|
259
|
+
isPrimaryKey: false;
|
|
260
|
+
isAutoincrement: false;
|
|
261
|
+
hasRuntimeDefault: false;
|
|
262
|
+
enumValues: undefined;
|
|
263
|
+
baseColumn: never;
|
|
264
|
+
identity: undefined;
|
|
265
|
+
generated: undefined;
|
|
266
|
+
}, {}, {}>;
|
|
233
267
|
};
|
|
234
268
|
dialect: "pg";
|
|
235
269
|
}>;
|
|
@@ -24,4 +24,6 @@ export const organizationInvites = pgTable("organization_invites", {
|
|
|
24
24
|
createdAt: bigint("created_at", { mode: "number" })
|
|
25
25
|
.notNull()
|
|
26
26
|
.default(sql `(extract(epoch from now()) * 1000)::bigint`),
|
|
27
|
+
acceptedAt: bigint("accepted_at", { mode: "number" }),
|
|
28
|
+
revokedAt: bigint("revoked_at", { mode: "number" }),
|
|
27
29
|
}, (table) => [index("idx_org_invites_org_id").on(table.orgId), index("idx_org_invites_token").on(table.token)]);
|
|
@@ -22,6 +22,8 @@ export interface OrgInviteRow {
|
|
|
22
22
|
token: string;
|
|
23
23
|
expiresAt: number;
|
|
24
24
|
createdAt: number;
|
|
25
|
+
acceptedAt: number | null;
|
|
26
|
+
revokedAt: number | null;
|
|
25
27
|
}
|
|
26
28
|
export interface IOrgMemberRepository {
|
|
27
29
|
listMembers(orgId: string): Promise<OrgMemberRow[]>;
|
|
@@ -37,6 +39,11 @@ export interface IOrgMemberRepository {
|
|
|
37
39
|
deleteInvite(inviteId: string): Promise<void>;
|
|
38
40
|
deleteAllMembers(orgId: string): Promise<void>;
|
|
39
41
|
deleteAllInvites(orgId: string): Promise<void>;
|
|
42
|
+
listOrgsByUser(userId: string): Promise<Array<{
|
|
43
|
+
orgId: string;
|
|
44
|
+
role: string;
|
|
45
|
+
}>>;
|
|
46
|
+
markInviteAccepted(inviteId: string): Promise<void>;
|
|
40
47
|
}
|
|
41
48
|
export declare class DrizzleOrgMemberRepository implements IOrgMemberRepository {
|
|
42
49
|
private readonly db;
|
|
@@ -54,4 +61,9 @@ export declare class DrizzleOrgMemberRepository implements IOrgMemberRepository
|
|
|
54
61
|
deleteInvite(inviteId: string): Promise<void>;
|
|
55
62
|
deleteAllMembers(orgId: string): Promise<void>;
|
|
56
63
|
deleteAllInvites(orgId: string): Promise<void>;
|
|
64
|
+
listOrgsByUser(userId: string): Promise<Array<{
|
|
65
|
+
orgId: string;
|
|
66
|
+
role: string;
|
|
67
|
+
}>>;
|
|
68
|
+
markInviteAccepted(inviteId: string): Promise<void>;
|
|
57
69
|
}
|
|
@@ -29,6 +29,8 @@ function toInvite(row) {
|
|
|
29
29
|
token: row.token,
|
|
30
30
|
expiresAt: row.expiresAt,
|
|
31
31
|
createdAt: row.createdAt,
|
|
32
|
+
acceptedAt: row.acceptedAt ?? null,
|
|
33
|
+
revokedAt: row.revokedAt ?? null,
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
export class DrizzleOrgMemberRepository {
|
|
@@ -96,4 +98,16 @@ export class DrizzleOrgMemberRepository {
|
|
|
96
98
|
async deleteAllInvites(orgId) {
|
|
97
99
|
await this.db.delete(organizationInvites).where(eq(organizationInvites.orgId, orgId));
|
|
98
100
|
}
|
|
101
|
+
async listOrgsByUser(userId) {
|
|
102
|
+
return this.db
|
|
103
|
+
.select({ orgId: organizationMembers.orgId, role: organizationMembers.role })
|
|
104
|
+
.from(organizationMembers)
|
|
105
|
+
.where(eq(organizationMembers.userId, userId));
|
|
106
|
+
}
|
|
107
|
+
async markInviteAccepted(inviteId) {
|
|
108
|
+
await this.db
|
|
109
|
+
.update(organizationInvites)
|
|
110
|
+
.set({ acceptedAt: Date.now() })
|
|
111
|
+
.where(eq(organizationInvites.id, inviteId));
|
|
112
|
+
}
|
|
99
113
|
}
|
|
@@ -67,8 +67,17 @@ export declare class OrgService {
|
|
|
67
67
|
changeRole(orgId: string, actorUserId: string, targetUserId: string, newRole: "admin" | "member"): Promise<void>;
|
|
68
68
|
removeMember(orgId: string, actorUserId: string, targetUserId: string): Promise<void>;
|
|
69
69
|
transferOwnership(orgId: string, actorUserId: string, targetUserId: string): Promise<void>;
|
|
70
|
+
acceptInvite(token: string, userId: string): Promise<{
|
|
71
|
+
orgId: string;
|
|
72
|
+
role: string;
|
|
73
|
+
}>;
|
|
74
|
+
listOrgsForUser(userId: string): Promise<Array<{
|
|
75
|
+
orgId: string;
|
|
76
|
+
role: string;
|
|
77
|
+
}>>;
|
|
70
78
|
validateSlug(slug: string): void;
|
|
71
79
|
private buildOrgWithMembers;
|
|
72
80
|
private requireOrg;
|
|
73
|
-
|
|
81
|
+
requireAdminOrOwner(orgId: string, userId: string): Promise<void>;
|
|
82
|
+
requireOwner(orgId: string, userId: string): Promise<void>;
|
|
74
83
|
}
|
|
@@ -121,6 +121,8 @@ export class OrgService {
|
|
|
121
121
|
token,
|
|
122
122
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
123
123
|
createdAt: Date.now(),
|
|
124
|
+
acceptedAt: null,
|
|
125
|
+
revokedAt: null,
|
|
124
126
|
};
|
|
125
127
|
await this.memberRepo.createInvite(invite);
|
|
126
128
|
return invite;
|
|
@@ -179,6 +181,37 @@ export class OrgService {
|
|
|
179
181
|
await this.memberRepo.updateMemberRole(orgId, actorUserId, "admin");
|
|
180
182
|
await this.orgRepo.updateOwner(orgId, targetUserId);
|
|
181
183
|
}
|
|
184
|
+
async acceptInvite(token, userId) {
|
|
185
|
+
const invite = await this.memberRepo.findInviteByToken(token);
|
|
186
|
+
if (!invite)
|
|
187
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
188
|
+
if (invite.acceptedAt)
|
|
189
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite already accepted" });
|
|
190
|
+
// revokedAt guard — currently no UI sets this field, but the schema supports it
|
|
191
|
+
// for future use (e.g., admin revokes an outstanding invite).
|
|
192
|
+
if (invite.revokedAt)
|
|
193
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite has been revoked" });
|
|
194
|
+
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
|
195
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite has expired" });
|
|
196
|
+
}
|
|
197
|
+
// Guard: reject if user is already a member (prevents consuming the invite silently)
|
|
198
|
+
const existing = await this.memberRepo.findMember(invite.orgId, userId);
|
|
199
|
+
if (existing) {
|
|
200
|
+
throw new TRPCError({ code: "CONFLICT", message: "User is already a member of this organization" });
|
|
201
|
+
}
|
|
202
|
+
await this.memberRepo.addMember({
|
|
203
|
+
id: crypto.randomUUID(),
|
|
204
|
+
orgId: invite.orgId,
|
|
205
|
+
userId,
|
|
206
|
+
role: invite.role ?? "member",
|
|
207
|
+
joinedAt: Date.now(),
|
|
208
|
+
});
|
|
209
|
+
await this.memberRepo.markInviteAccepted(invite.id);
|
|
210
|
+
return { orgId: invite.orgId, role: invite.role ?? "member" };
|
|
211
|
+
}
|
|
212
|
+
async listOrgsForUser(userId) {
|
|
213
|
+
return this.memberRepo.listOrgsByUser(userId);
|
|
214
|
+
}
|
|
182
215
|
validateSlug(slug) {
|
|
183
216
|
if (!/^[a-z0-9][a-z0-9-]{1,46}[a-z0-9]$/.test(slug)) {
|
|
184
217
|
throw new TRPCError({
|
|
@@ -238,4 +271,10 @@ export class OrgService {
|
|
|
238
271
|
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
239
272
|
}
|
|
240
273
|
}
|
|
274
|
+
async requireOwner(orgId, userId) {
|
|
275
|
+
const member = await this.memberRepo.findMember(orgId, userId);
|
|
276
|
+
if (!member || member.role !== "owner") {
|
|
277
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Owner role required" });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
241
280
|
}
|
|
@@ -325,6 +325,8 @@ describe("OrgService", () => {
|
|
|
325
325
|
token: "tok-d3",
|
|
326
326
|
expiresAt: Date.now() + 86400000,
|
|
327
327
|
createdAt: Date.now(),
|
|
328
|
+
acceptedAt: null,
|
|
329
|
+
revokedAt: null,
|
|
328
330
|
});
|
|
329
331
|
await svc.deleteOrg(org.id, owner);
|
|
330
332
|
expect(await orgRepo.getById(org.id)).toBeNull();
|
|
@@ -547,6 +549,223 @@ describe("OrgService", () => {
|
|
|
547
549
|
await expect(svc.removeMember(org.id, owner, admin)).resolves.not.toThrow();
|
|
548
550
|
});
|
|
549
551
|
});
|
|
552
|
+
describe("listOrgsForUser", () => {
|
|
553
|
+
it("returns orgs the user is a member of", async () => {
|
|
554
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
555
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
556
|
+
const owner = "owner-lof1";
|
|
557
|
+
const org1 = await orgRepo.createOrg(owner, "List Org 1", "list-org-1");
|
|
558
|
+
await memberRepo.addMember({
|
|
559
|
+
id: "mlof1",
|
|
560
|
+
orgId: org1.id,
|
|
561
|
+
userId: owner,
|
|
562
|
+
role: "owner",
|
|
563
|
+
joinedAt: Date.now(),
|
|
564
|
+
});
|
|
565
|
+
const org2 = await orgRepo.createOrg(owner, "List Org 2", "list-org-2");
|
|
566
|
+
await memberRepo.addMember({
|
|
567
|
+
id: "mlof2",
|
|
568
|
+
orgId: org2.id,
|
|
569
|
+
userId: owner,
|
|
570
|
+
role: "admin",
|
|
571
|
+
joinedAt: Date.now(),
|
|
572
|
+
});
|
|
573
|
+
const result = await svc.listOrgsForUser(owner);
|
|
574
|
+
expect(result).toHaveLength(2);
|
|
575
|
+
expect(result.map((r) => r.orgId).sort()).toEqual([org1.id, org2.id].sort());
|
|
576
|
+
});
|
|
577
|
+
it("returns empty array for unknown user", async () => {
|
|
578
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
579
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
580
|
+
const result = await svc.listOrgsForUser("totally-unknown-user");
|
|
581
|
+
expect(result).toEqual([]);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
describe("acceptInvite", () => {
|
|
585
|
+
it("accepts a valid invite and creates membership", async () => {
|
|
586
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
587
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
588
|
+
const owner = "owner-ai1";
|
|
589
|
+
const org = await orgRepo.createOrg(owner, "Accept Org", "accept-org");
|
|
590
|
+
await memberRepo.addMember({
|
|
591
|
+
id: "mai1",
|
|
592
|
+
orgId: org.id,
|
|
593
|
+
userId: owner,
|
|
594
|
+
role: "owner",
|
|
595
|
+
joinedAt: Date.now(),
|
|
596
|
+
});
|
|
597
|
+
const invite = await svc.inviteMember(org.id, owner, "new@example.com", "admin");
|
|
598
|
+
const result = await svc.acceptInvite(invite.token, "new-user-ai1");
|
|
599
|
+
expect(result).toEqual({ orgId: org.id, role: "admin" });
|
|
600
|
+
// Verify membership was created
|
|
601
|
+
const member = await memberRepo.findMember(org.id, "new-user-ai1");
|
|
602
|
+
expect(member).not.toBeNull();
|
|
603
|
+
expect(member?.role).toBe("admin");
|
|
604
|
+
// Verify invite was marked accepted
|
|
605
|
+
const updatedInvite = await memberRepo.findInviteByToken(invite.token);
|
|
606
|
+
expect(updatedInvite?.acceptedAt).not.toBeNull();
|
|
607
|
+
});
|
|
608
|
+
it("throws NOT_FOUND for unknown token", async () => {
|
|
609
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
610
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
611
|
+
await expect(svc.acceptInvite("bad-token", "user-1")).rejects.toThrow(expect.objectContaining({ code: "NOT_FOUND" }));
|
|
612
|
+
});
|
|
613
|
+
it("throws BAD_REQUEST for already-accepted invite", async () => {
|
|
614
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
615
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
616
|
+
const owner = "owner-ai2";
|
|
617
|
+
const org = await orgRepo.createOrg(owner, "Accept Org 2", "accept-org-2");
|
|
618
|
+
await memberRepo.addMember({
|
|
619
|
+
id: "mai2",
|
|
620
|
+
orgId: org.id,
|
|
621
|
+
userId: owner,
|
|
622
|
+
role: "owner",
|
|
623
|
+
joinedAt: Date.now(),
|
|
624
|
+
});
|
|
625
|
+
const invite = await svc.inviteMember(org.id, owner, "dup@example.com", "member");
|
|
626
|
+
await svc.acceptInvite(invite.token, "first-user");
|
|
627
|
+
await expect(svc.acceptInvite(invite.token, "second-user")).rejects.toThrow(expect.objectContaining({ code: "BAD_REQUEST", message: "Invite already accepted" }));
|
|
628
|
+
});
|
|
629
|
+
it("throws BAD_REQUEST for expired invite", async () => {
|
|
630
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
631
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
632
|
+
const owner = "owner-ai3";
|
|
633
|
+
const org = await orgRepo.createOrg(owner, "Accept Org 3", "accept-org-3");
|
|
634
|
+
await memberRepo.addMember({
|
|
635
|
+
id: "mai3",
|
|
636
|
+
orgId: org.id,
|
|
637
|
+
userId: owner,
|
|
638
|
+
role: "owner",
|
|
639
|
+
joinedAt: Date.now(),
|
|
640
|
+
});
|
|
641
|
+
// Create an expired invite directly
|
|
642
|
+
await memberRepo.createInvite({
|
|
643
|
+
id: "inv-expired",
|
|
644
|
+
orgId: org.id,
|
|
645
|
+
email: "expired@example.com",
|
|
646
|
+
role: "member",
|
|
647
|
+
invitedBy: owner,
|
|
648
|
+
token: "tok-expired",
|
|
649
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
650
|
+
createdAt: Date.now() - 86400000,
|
|
651
|
+
acceptedAt: null,
|
|
652
|
+
revokedAt: null,
|
|
653
|
+
});
|
|
654
|
+
await expect(svc.acceptInvite("tok-expired", "late-user")).rejects.toThrow(expect.objectContaining({ code: "BAD_REQUEST", message: "Invite has expired" }));
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
describe("requireAdminOrOwner", () => {
|
|
658
|
+
it("passes for admin", async () => {
|
|
659
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
660
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
661
|
+
const owner = "owner-rao1";
|
|
662
|
+
const admin = "admin-rao1";
|
|
663
|
+
const org = await orgRepo.createOrg(owner, "RAO Org", "rao-org");
|
|
664
|
+
await memberRepo.addMember({
|
|
665
|
+
id: "mrao1",
|
|
666
|
+
orgId: org.id,
|
|
667
|
+
userId: owner,
|
|
668
|
+
role: "owner",
|
|
669
|
+
joinedAt: Date.now(),
|
|
670
|
+
});
|
|
671
|
+
await memberRepo.addMember({
|
|
672
|
+
id: "mrao2",
|
|
673
|
+
orgId: org.id,
|
|
674
|
+
userId: admin,
|
|
675
|
+
role: "admin",
|
|
676
|
+
joinedAt: Date.now(),
|
|
677
|
+
});
|
|
678
|
+
await expect(svc.requireAdminOrOwner(org.id, admin)).resolves.toBeUndefined();
|
|
679
|
+
});
|
|
680
|
+
it("passes for owner", async () => {
|
|
681
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
682
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
683
|
+
const owner = "owner-rao2";
|
|
684
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 2", "rao-org-2");
|
|
685
|
+
await memberRepo.addMember({
|
|
686
|
+
id: "mrao3",
|
|
687
|
+
orgId: org.id,
|
|
688
|
+
userId: owner,
|
|
689
|
+
role: "owner",
|
|
690
|
+
joinedAt: Date.now(),
|
|
691
|
+
});
|
|
692
|
+
await expect(svc.requireAdminOrOwner(org.id, owner)).resolves.toBeUndefined();
|
|
693
|
+
});
|
|
694
|
+
it("throws FORBIDDEN for regular member", async () => {
|
|
695
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
696
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
697
|
+
const owner = "owner-rao3";
|
|
698
|
+
const member = "member-rao3";
|
|
699
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 3", "rao-org-3");
|
|
700
|
+
await memberRepo.addMember({
|
|
701
|
+
id: "mrao4",
|
|
702
|
+
orgId: org.id,
|
|
703
|
+
userId: owner,
|
|
704
|
+
role: "owner",
|
|
705
|
+
joinedAt: Date.now(),
|
|
706
|
+
});
|
|
707
|
+
await memberRepo.addMember({
|
|
708
|
+
id: "mrao5",
|
|
709
|
+
orgId: org.id,
|
|
710
|
+
userId: member,
|
|
711
|
+
role: "member",
|
|
712
|
+
joinedAt: Date.now(),
|
|
713
|
+
});
|
|
714
|
+
await expect(svc.requireAdminOrOwner(org.id, member)).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
|
715
|
+
});
|
|
716
|
+
it("throws FORBIDDEN for non-member", async () => {
|
|
717
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
718
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
719
|
+
const owner = "owner-rao4";
|
|
720
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 4", "rao-org-4");
|
|
721
|
+
await memberRepo.addMember({
|
|
722
|
+
id: "mrao6",
|
|
723
|
+
orgId: org.id,
|
|
724
|
+
userId: owner,
|
|
725
|
+
role: "owner",
|
|
726
|
+
joinedAt: Date.now(),
|
|
727
|
+
});
|
|
728
|
+
await expect(svc.requireAdminOrOwner(org.id, "stranger")).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
describe("requireOwner", () => {
|
|
732
|
+
it("passes for owner", async () => {
|
|
733
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
734
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
735
|
+
const owner = "owner-ro1";
|
|
736
|
+
const org = await orgRepo.createOrg(owner, "RO Org", "ro-org");
|
|
737
|
+
await memberRepo.addMember({
|
|
738
|
+
id: "mro1",
|
|
739
|
+
orgId: org.id,
|
|
740
|
+
userId: owner,
|
|
741
|
+
role: "owner",
|
|
742
|
+
joinedAt: Date.now(),
|
|
743
|
+
});
|
|
744
|
+
await expect(svc.requireOwner(org.id, owner)).resolves.toBeUndefined();
|
|
745
|
+
});
|
|
746
|
+
it("throws FORBIDDEN for admin", async () => {
|
|
747
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
748
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
749
|
+
const owner = "owner-ro2";
|
|
750
|
+
const admin = "admin-ro2";
|
|
751
|
+
const org = await orgRepo.createOrg(owner, "RO Org 2", "ro-org-2");
|
|
752
|
+
await memberRepo.addMember({
|
|
753
|
+
id: "mro2",
|
|
754
|
+
orgId: org.id,
|
|
755
|
+
userId: owner,
|
|
756
|
+
role: "owner",
|
|
757
|
+
joinedAt: Date.now(),
|
|
758
|
+
});
|
|
759
|
+
await memberRepo.addMember({
|
|
760
|
+
id: "mro3",
|
|
761
|
+
orgId: org.id,
|
|
762
|
+
userId: admin,
|
|
763
|
+
role: "admin",
|
|
764
|
+
joinedAt: Date.now(),
|
|
765
|
+
});
|
|
766
|
+
await expect(svc.requireOwner(org.id, admin)).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
|
767
|
+
});
|
|
768
|
+
});
|
|
550
769
|
describe("buildOrgWithMembers member resolution", () => {
|
|
551
770
|
it("resolves member name and email from userRepo when provided", async () => {
|
|
552
771
|
await pool.query(`CREATE TABLE IF NOT EXISTS "user" (id TEXT PRIMARY KEY, name TEXT, email TEXT, image TEXT, "twoFactorEnabled" BOOLEAN DEFAULT false)`);
|
package/dist/trpc/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { adminProcedure, createCallerFactory, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
|
|
1
|
+
export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
|
package/dist/trpc/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { adminProcedure, createCallerFactory, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
|
|
1
|
+
export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
|
package/dist/trpc/init.d.ts
CHANGED
|
@@ -46,4 +46,11 @@ export declare const tenantProcedure: import("@trpc/server").TRPCProcedureBuilde
|
|
|
46
46
|
export declare const orgMemberProcedure: import("@trpc/server").TRPCProcedureBuilder<TRPCContext, object, {
|
|
47
47
|
tenantId: string | undefined;
|
|
48
48
|
user: AuthUser;
|
|
49
|
+
orgRole: "member" | "admin" | "owner";
|
|
50
|
+
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
51
|
+
/** Procedure that requires authentication + org admin/owner role (orgId must be in input). */
|
|
52
|
+
export declare const orgAdminProcedure: import("@trpc/server").TRPCProcedureBuilder<TRPCContext, object, {
|
|
53
|
+
tenantId: string | undefined;
|
|
54
|
+
user: AuthUser | undefined;
|
|
55
|
+
orgRole: "member" | "admin" | "owner";
|
|
49
56
|
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
package/dist/trpc/init.js
CHANGED
|
@@ -120,7 +120,22 @@ const isOrgMember = t.middleware(async ({ ctx, next, getRawInput }) => {
|
|
|
120
120
|
if (!member) {
|
|
121
121
|
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this organization" });
|
|
122
122
|
}
|
|
123
|
-
return next({
|
|
123
|
+
return next({
|
|
124
|
+
ctx: { user: ctx.user, tenantId: ctx.tenantId, orgRole: member.role },
|
|
125
|
+
});
|
|
124
126
|
});
|
|
125
127
|
/** Procedure that requires authentication + org membership (orgId must be in input). */
|
|
126
128
|
export const orgMemberProcedure = t.procedure.use(isAuthed).use(isOrgMember);
|
|
129
|
+
/**
|
|
130
|
+
* Middleware that enforces admin or owner role within an org.
|
|
131
|
+
* Must be chained after isOrgMember so ctx.orgRole is guaranteed.
|
|
132
|
+
*/
|
|
133
|
+
const isOrgAdmin = t.middleware(async ({ ctx, next }) => {
|
|
134
|
+
const role = ctx.orgRole;
|
|
135
|
+
if (role === "member") {
|
|
136
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
137
|
+
}
|
|
138
|
+
return next({ ctx });
|
|
139
|
+
});
|
|
140
|
+
/** Procedure that requires authentication + org admin/owner role (orgId must be in input). */
|
|
141
|
+
export const orgAdminProcedure = orgMemberProcedure.use(isOrgAdmin);
|
package/dist/trpc/init.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { adminProcedure, createCallerFactory, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
|
|
2
|
+
import { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Helpers
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -19,6 +19,8 @@ function makeMockRepo(overrides) {
|
|
|
19
19
|
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
20
20
|
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
21
21
|
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
23
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
22
24
|
...overrides,
|
|
23
25
|
};
|
|
24
26
|
}
|
|
@@ -29,6 +31,7 @@ const appRouter = router({
|
|
|
29
31
|
adminHello: adminProcedure.query(() => "admin-ok"),
|
|
30
32
|
tenantHello: tenantProcedure.query(() => "tenant-ok"),
|
|
31
33
|
orgAction: orgMemberProcedure.input((v) => v).mutation(() => "org-ok"),
|
|
34
|
+
orgAdminAction: orgAdminProcedure.input((v) => v).mutation(() => "org-admin-ok"),
|
|
32
35
|
});
|
|
33
36
|
const createCaller = createCallerFactory(appRouter);
|
|
34
37
|
// ---------------------------------------------------------------------------
|
|
@@ -187,4 +190,39 @@ describe("tRPC procedure builders", () => {
|
|
|
187
190
|
expect(await caller.orgAction({ orgId: "org-1" })).toBe("org-ok");
|
|
188
191
|
});
|
|
189
192
|
});
|
|
193
|
+
// -----------------------------------------------------------------------
|
|
194
|
+
// orgAdminProcedure
|
|
195
|
+
// -----------------------------------------------------------------------
|
|
196
|
+
describe("orgAdminProcedure", () => {
|
|
197
|
+
it("rejects regular members", async () => {
|
|
198
|
+
const repo = makeMockRepo({
|
|
199
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "member", joinedAt: 0 }),
|
|
200
|
+
});
|
|
201
|
+
setTrpcOrgMemberRepo(repo);
|
|
202
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
203
|
+
await expect(caller.orgAdminAction({ orgId: "org-1" })).rejects.toThrow("Admin or owner role required");
|
|
204
|
+
});
|
|
205
|
+
it("allows admin members", async () => {
|
|
206
|
+
const repo = makeMockRepo({
|
|
207
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "admin", joinedAt: 0 }),
|
|
208
|
+
});
|
|
209
|
+
setTrpcOrgMemberRepo(repo);
|
|
210
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
211
|
+
expect(await caller.orgAdminAction({ orgId: "org-1" })).toBe("org-admin-ok");
|
|
212
|
+
});
|
|
213
|
+
it("allows owner members", async () => {
|
|
214
|
+
const repo = makeMockRepo({
|
|
215
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "owner", joinedAt: 0 }),
|
|
216
|
+
});
|
|
217
|
+
setTrpcOrgMemberRepo(repo);
|
|
218
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
219
|
+
expect(await caller.orgAdminAction({ orgId: "org-1" })).toBe("org-admin-ok");
|
|
220
|
+
});
|
|
221
|
+
it("rejects non-members", async () => {
|
|
222
|
+
const repo = makeMockRepo({ findMember: vi.fn().mockResolvedValue(null) });
|
|
223
|
+
setTrpcOrgMemberRepo(repo);
|
|
224
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
225
|
+
await expect(caller.orgAdminAction({ orgId: "org-1" })).rejects.toThrow("Not a member of this organization");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
190
228
|
});
|
package/package.json
CHANGED
|
@@ -17,6 +17,8 @@ function mockOrgMemberRepo(overrides: Partial<IOrgMemberRepository> = {}): IOrgM
|
|
|
17
17
|
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
18
18
|
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
19
19
|
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
21
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
20
22
|
...overrides,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
@@ -32,6 +32,8 @@ export const organizationInvites = pgTable(
|
|
|
32
32
|
createdAt: bigint("created_at", { mode: "number" })
|
|
33
33
|
.notNull()
|
|
34
34
|
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
|
|
35
|
+
acceptedAt: bigint("accepted_at", { mode: "number" }),
|
|
36
|
+
revokedAt: bigint("revoked_at", { mode: "number" }),
|
|
35
37
|
},
|
|
36
38
|
(table) => [index("idx_org_invites_org_id").on(table.orgId), index("idx_org_invites_token").on(table.token)],
|
|
37
39
|
);
|
|
@@ -31,6 +31,8 @@ export interface OrgInviteRow {
|
|
|
31
31
|
token: string;
|
|
32
32
|
expiresAt: number;
|
|
33
33
|
createdAt: number;
|
|
34
|
+
acceptedAt: number | null;
|
|
35
|
+
revokedAt: number | null;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
// ---------------------------------------------------------------------------
|
|
@@ -52,6 +54,8 @@ export interface IOrgMemberRepository {
|
|
|
52
54
|
deleteInvite(inviteId: string): Promise<void>;
|
|
53
55
|
deleteAllMembers(orgId: string): Promise<void>;
|
|
54
56
|
deleteAllInvites(orgId: string): Promise<void>;
|
|
57
|
+
listOrgsByUser(userId: string): Promise<Array<{ orgId: string; role: string }>>;
|
|
58
|
+
markInviteAccepted(inviteId: string): Promise<void>;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// ---------------------------------------------------------------------------
|
|
@@ -78,6 +82,8 @@ function toInvite(row: typeof organizationInvites.$inferSelect): OrgInviteRow {
|
|
|
78
82
|
token: row.token,
|
|
79
83
|
expiresAt: row.expiresAt,
|
|
80
84
|
createdAt: row.createdAt,
|
|
85
|
+
acceptedAt: row.acceptedAt ?? null,
|
|
86
|
+
revokedAt: row.revokedAt ?? null,
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -156,4 +162,18 @@ export class DrizzleOrgMemberRepository implements IOrgMemberRepository {
|
|
|
156
162
|
async deleteAllInvites(orgId: string): Promise<void> {
|
|
157
163
|
await this.db.delete(organizationInvites).where(eq(organizationInvites.orgId, orgId));
|
|
158
164
|
}
|
|
165
|
+
|
|
166
|
+
async listOrgsByUser(userId: string): Promise<Array<{ orgId: string; role: string }>> {
|
|
167
|
+
return this.db
|
|
168
|
+
.select({ orgId: organizationMembers.orgId, role: organizationMembers.role })
|
|
169
|
+
.from(organizationMembers)
|
|
170
|
+
.where(eq(organizationMembers.userId, userId));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async markInviteAccepted(inviteId: string): Promise<void> {
|
|
174
|
+
await this.db
|
|
175
|
+
.update(organizationInvites)
|
|
176
|
+
.set({ acceptedAt: Date.now() })
|
|
177
|
+
.where(eq(organizationInvites.id, inviteId));
|
|
178
|
+
}
|
|
159
179
|
}
|
|
@@ -382,6 +382,8 @@ describe("OrgService", () => {
|
|
|
382
382
|
token: "tok-d3",
|
|
383
383
|
expiresAt: Date.now() + 86400000,
|
|
384
384
|
createdAt: Date.now(),
|
|
385
|
+
acceptedAt: null,
|
|
386
|
+
revokedAt: null,
|
|
385
387
|
});
|
|
386
388
|
|
|
387
389
|
await svc.deleteOrg(org.id, owner);
|
|
@@ -632,6 +634,264 @@ describe("OrgService", () => {
|
|
|
632
634
|
});
|
|
633
635
|
});
|
|
634
636
|
|
|
637
|
+
describe("listOrgsForUser", () => {
|
|
638
|
+
it("returns orgs the user is a member of", async () => {
|
|
639
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
640
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
641
|
+
const owner = "owner-lof1";
|
|
642
|
+
|
|
643
|
+
const org1 = await orgRepo.createOrg(owner, "List Org 1", "list-org-1");
|
|
644
|
+
await memberRepo.addMember({
|
|
645
|
+
id: "mlof1",
|
|
646
|
+
orgId: org1.id,
|
|
647
|
+
userId: owner,
|
|
648
|
+
role: "owner",
|
|
649
|
+
joinedAt: Date.now(),
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const org2 = await orgRepo.createOrg(owner, "List Org 2", "list-org-2");
|
|
653
|
+
await memberRepo.addMember({
|
|
654
|
+
id: "mlof2",
|
|
655
|
+
orgId: org2.id,
|
|
656
|
+
userId: owner,
|
|
657
|
+
role: "admin",
|
|
658
|
+
joinedAt: Date.now(),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const result = await svc.listOrgsForUser(owner);
|
|
662
|
+
expect(result).toHaveLength(2);
|
|
663
|
+
expect(result.map((r) => r.orgId).sort()).toEqual([org1.id, org2.id].sort());
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("returns empty array for unknown user", async () => {
|
|
667
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
668
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
669
|
+
|
|
670
|
+
const result = await svc.listOrgsForUser("totally-unknown-user");
|
|
671
|
+
expect(result).toEqual([]);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("acceptInvite", () => {
|
|
676
|
+
it("accepts a valid invite and creates membership", async () => {
|
|
677
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
678
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
679
|
+
const owner = "owner-ai1";
|
|
680
|
+
const org = await orgRepo.createOrg(owner, "Accept Org", "accept-org");
|
|
681
|
+
await memberRepo.addMember({
|
|
682
|
+
id: "mai1",
|
|
683
|
+
orgId: org.id,
|
|
684
|
+
userId: owner,
|
|
685
|
+
role: "owner",
|
|
686
|
+
joinedAt: Date.now(),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const invite = await svc.inviteMember(org.id, owner, "new@example.com", "admin");
|
|
690
|
+
const result = await svc.acceptInvite(invite.token, "new-user-ai1");
|
|
691
|
+
|
|
692
|
+
expect(result).toEqual({ orgId: org.id, role: "admin" });
|
|
693
|
+
|
|
694
|
+
// Verify membership was created
|
|
695
|
+
const member = await memberRepo.findMember(org.id, "new-user-ai1");
|
|
696
|
+
expect(member).not.toBeNull();
|
|
697
|
+
expect(member?.role).toBe("admin");
|
|
698
|
+
|
|
699
|
+
// Verify invite was marked accepted
|
|
700
|
+
const updatedInvite = await memberRepo.findInviteByToken(invite.token);
|
|
701
|
+
expect(updatedInvite?.acceptedAt).not.toBeNull();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("throws NOT_FOUND for unknown token", async () => {
|
|
705
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
706
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
707
|
+
|
|
708
|
+
await expect(svc.acceptInvite("bad-token", "user-1")).rejects.toThrow(
|
|
709
|
+
expect.objectContaining({ code: "NOT_FOUND" }),
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("throws BAD_REQUEST for already-accepted invite", async () => {
|
|
714
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
715
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
716
|
+
const owner = "owner-ai2";
|
|
717
|
+
const org = await orgRepo.createOrg(owner, "Accept Org 2", "accept-org-2");
|
|
718
|
+
await memberRepo.addMember({
|
|
719
|
+
id: "mai2",
|
|
720
|
+
orgId: org.id,
|
|
721
|
+
userId: owner,
|
|
722
|
+
role: "owner",
|
|
723
|
+
joinedAt: Date.now(),
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
const invite = await svc.inviteMember(org.id, owner, "dup@example.com", "member");
|
|
727
|
+
await svc.acceptInvite(invite.token, "first-user");
|
|
728
|
+
|
|
729
|
+
await expect(svc.acceptInvite(invite.token, "second-user")).rejects.toThrow(
|
|
730
|
+
expect.objectContaining({ code: "BAD_REQUEST", message: "Invite already accepted" }),
|
|
731
|
+
);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("throws BAD_REQUEST for expired invite", async () => {
|
|
735
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
736
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
737
|
+
const owner = "owner-ai3";
|
|
738
|
+
const org = await orgRepo.createOrg(owner, "Accept Org 3", "accept-org-3");
|
|
739
|
+
await memberRepo.addMember({
|
|
740
|
+
id: "mai3",
|
|
741
|
+
orgId: org.id,
|
|
742
|
+
userId: owner,
|
|
743
|
+
role: "owner",
|
|
744
|
+
joinedAt: Date.now(),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Create an expired invite directly
|
|
748
|
+
await memberRepo.createInvite({
|
|
749
|
+
id: "inv-expired",
|
|
750
|
+
orgId: org.id,
|
|
751
|
+
email: "expired@example.com",
|
|
752
|
+
role: "member",
|
|
753
|
+
invitedBy: owner,
|
|
754
|
+
token: "tok-expired",
|
|
755
|
+
expiresAt: Date.now() - 1000, // already expired
|
|
756
|
+
createdAt: Date.now() - 86400000,
|
|
757
|
+
acceptedAt: null,
|
|
758
|
+
revokedAt: null,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
await expect(svc.acceptInvite("tok-expired", "late-user")).rejects.toThrow(
|
|
762
|
+
expect.objectContaining({ code: "BAD_REQUEST", message: "Invite has expired" }),
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe("requireAdminOrOwner", () => {
|
|
768
|
+
it("passes for admin", async () => {
|
|
769
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
770
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
771
|
+
const owner = "owner-rao1";
|
|
772
|
+
const admin = "admin-rao1";
|
|
773
|
+
const org = await orgRepo.createOrg(owner, "RAO Org", "rao-org");
|
|
774
|
+
await memberRepo.addMember({
|
|
775
|
+
id: "mrao1",
|
|
776
|
+
orgId: org.id,
|
|
777
|
+
userId: owner,
|
|
778
|
+
role: "owner",
|
|
779
|
+
joinedAt: Date.now(),
|
|
780
|
+
});
|
|
781
|
+
await memberRepo.addMember({
|
|
782
|
+
id: "mrao2",
|
|
783
|
+
orgId: org.id,
|
|
784
|
+
userId: admin,
|
|
785
|
+
role: "admin",
|
|
786
|
+
joinedAt: Date.now(),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
await expect(svc.requireAdminOrOwner(org.id, admin)).resolves.toBeUndefined();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("passes for owner", async () => {
|
|
793
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
794
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
795
|
+
const owner = "owner-rao2";
|
|
796
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 2", "rao-org-2");
|
|
797
|
+
await memberRepo.addMember({
|
|
798
|
+
id: "mrao3",
|
|
799
|
+
orgId: org.id,
|
|
800
|
+
userId: owner,
|
|
801
|
+
role: "owner",
|
|
802
|
+
joinedAt: Date.now(),
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
await expect(svc.requireAdminOrOwner(org.id, owner)).resolves.toBeUndefined();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("throws FORBIDDEN for regular member", async () => {
|
|
809
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
810
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
811
|
+
const owner = "owner-rao3";
|
|
812
|
+
const member = "member-rao3";
|
|
813
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 3", "rao-org-3");
|
|
814
|
+
await memberRepo.addMember({
|
|
815
|
+
id: "mrao4",
|
|
816
|
+
orgId: org.id,
|
|
817
|
+
userId: owner,
|
|
818
|
+
role: "owner",
|
|
819
|
+
joinedAt: Date.now(),
|
|
820
|
+
});
|
|
821
|
+
await memberRepo.addMember({
|
|
822
|
+
id: "mrao5",
|
|
823
|
+
orgId: org.id,
|
|
824
|
+
userId: member,
|
|
825
|
+
role: "member",
|
|
826
|
+
joinedAt: Date.now(),
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
await expect(svc.requireAdminOrOwner(org.id, member)).rejects.toThrow(
|
|
830
|
+
expect.objectContaining({ code: "FORBIDDEN" }),
|
|
831
|
+
);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("throws FORBIDDEN for non-member", async () => {
|
|
835
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
836
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
837
|
+
const owner = "owner-rao4";
|
|
838
|
+
const org = await orgRepo.createOrg(owner, "RAO Org 4", "rao-org-4");
|
|
839
|
+
await memberRepo.addMember({
|
|
840
|
+
id: "mrao6",
|
|
841
|
+
orgId: org.id,
|
|
842
|
+
userId: owner,
|
|
843
|
+
role: "owner",
|
|
844
|
+
joinedAt: Date.now(),
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
await expect(svc.requireAdminOrOwner(org.id, "stranger")).rejects.toThrow(
|
|
848
|
+
expect.objectContaining({ code: "FORBIDDEN" }),
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe("requireOwner", () => {
|
|
854
|
+
it("passes for owner", async () => {
|
|
855
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
856
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
857
|
+
const owner = "owner-ro1";
|
|
858
|
+
const org = await orgRepo.createOrg(owner, "RO Org", "ro-org");
|
|
859
|
+
await memberRepo.addMember({
|
|
860
|
+
id: "mro1",
|
|
861
|
+
orgId: org.id,
|
|
862
|
+
userId: owner,
|
|
863
|
+
role: "owner",
|
|
864
|
+
joinedAt: Date.now(),
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
await expect(svc.requireOwner(org.id, owner)).resolves.toBeUndefined();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("throws FORBIDDEN for admin", async () => {
|
|
871
|
+
const { orgRepo, memberRepo } = await setup(db);
|
|
872
|
+
const svc = new OrgService(orgRepo, memberRepo, db);
|
|
873
|
+
const owner = "owner-ro2";
|
|
874
|
+
const admin = "admin-ro2";
|
|
875
|
+
const org = await orgRepo.createOrg(owner, "RO Org 2", "ro-org-2");
|
|
876
|
+
await memberRepo.addMember({
|
|
877
|
+
id: "mro2",
|
|
878
|
+
orgId: org.id,
|
|
879
|
+
userId: owner,
|
|
880
|
+
role: "owner",
|
|
881
|
+
joinedAt: Date.now(),
|
|
882
|
+
});
|
|
883
|
+
await memberRepo.addMember({
|
|
884
|
+
id: "mro3",
|
|
885
|
+
orgId: org.id,
|
|
886
|
+
userId: admin,
|
|
887
|
+
role: "admin",
|
|
888
|
+
joinedAt: Date.now(),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
await expect(svc.requireOwner(org.id, admin)).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
635
895
|
describe("buildOrgWithMembers member resolution", () => {
|
|
636
896
|
it("resolves member name and email from userRepo when provided", async () => {
|
|
637
897
|
await pool.query(
|
|
@@ -179,6 +179,8 @@ export class OrgService {
|
|
|
179
179
|
token,
|
|
180
180
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
|
181
181
|
createdAt: Date.now(),
|
|
182
|
+
acceptedAt: null,
|
|
183
|
+
revokedAt: null,
|
|
182
184
|
};
|
|
183
185
|
await this.memberRepo.createInvite(invite);
|
|
184
186
|
return invite;
|
|
@@ -247,6 +249,38 @@ export class OrgService {
|
|
|
247
249
|
await this.orgRepo.updateOwner(orgId, targetUserId);
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
async acceptInvite(token: string, userId: string): Promise<{ orgId: string; role: string }> {
|
|
253
|
+
const invite = await this.memberRepo.findInviteByToken(token);
|
|
254
|
+
if (!invite) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
255
|
+
if (invite.acceptedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Invite already accepted" });
|
|
256
|
+
// revokedAt guard — currently no UI sets this field, but the schema supports it
|
|
257
|
+
// for future use (e.g., admin revokes an outstanding invite).
|
|
258
|
+
if (invite.revokedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Invite has been revoked" });
|
|
259
|
+
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
|
260
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite has expired" });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Guard: reject if user is already a member (prevents consuming the invite silently)
|
|
264
|
+
const existing = await this.memberRepo.findMember(invite.orgId, userId);
|
|
265
|
+
if (existing) {
|
|
266
|
+
throw new TRPCError({ code: "CONFLICT", message: "User is already a member of this organization" });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await this.memberRepo.addMember({
|
|
270
|
+
id: crypto.randomUUID(),
|
|
271
|
+
orgId: invite.orgId,
|
|
272
|
+
userId,
|
|
273
|
+
role: invite.role ?? "member",
|
|
274
|
+
joinedAt: Date.now(),
|
|
275
|
+
});
|
|
276
|
+
await this.memberRepo.markInviteAccepted(invite.id);
|
|
277
|
+
return { orgId: invite.orgId, role: invite.role ?? "member" };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async listOrgsForUser(userId: string): Promise<Array<{ orgId: string; role: string }>> {
|
|
281
|
+
return this.memberRepo.listOrgsByUser(userId);
|
|
282
|
+
}
|
|
283
|
+
|
|
250
284
|
validateSlug(slug: string): void {
|
|
251
285
|
if (!/^[a-z0-9][a-z0-9-]{1,46}[a-z0-9]$/.test(slug)) {
|
|
252
286
|
throw new TRPCError({
|
|
@@ -304,10 +338,17 @@ export class OrgService {
|
|
|
304
338
|
return org;
|
|
305
339
|
}
|
|
306
340
|
|
|
307
|
-
|
|
341
|
+
async requireAdminOrOwner(orgId: string, userId: string): Promise<void> {
|
|
308
342
|
const member = await this.memberRepo.findMember(orgId, userId);
|
|
309
343
|
if (!member || (member.role !== "admin" && member.role !== "owner")) {
|
|
310
344
|
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
311
345
|
}
|
|
312
346
|
}
|
|
347
|
+
|
|
348
|
+
async requireOwner(orgId: string, userId: string): Promise<void> {
|
|
349
|
+
const member = await this.memberRepo.findMember(orgId, userId);
|
|
350
|
+
if (!member || member.role !== "owner") {
|
|
351
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Owner role required" });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
313
354
|
}
|
package/src/trpc/index.ts
CHANGED
package/src/trpc/init.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
|
3
3
|
import {
|
|
4
4
|
adminProcedure,
|
|
5
5
|
createCallerFactory,
|
|
6
|
+
orgAdminProcedure,
|
|
6
7
|
orgMemberProcedure,
|
|
7
8
|
protectedProcedure,
|
|
8
9
|
publicProcedure,
|
|
@@ -31,6 +32,8 @@ function makeMockRepo(overrides?: Partial<IOrgMemberRepository>): IOrgMemberRepo
|
|
|
31
32
|
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
32
33
|
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
33
34
|
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
36
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
34
37
|
...overrides,
|
|
35
38
|
};
|
|
36
39
|
}
|
|
@@ -42,6 +45,7 @@ const appRouter = router({
|
|
|
42
45
|
adminHello: adminProcedure.query(() => "admin-ok"),
|
|
43
46
|
tenantHello: tenantProcedure.query(() => "tenant-ok"),
|
|
44
47
|
orgAction: orgMemberProcedure.input((v: unknown) => v as { orgId: string }).mutation(() => "org-ok"),
|
|
48
|
+
orgAdminAction: orgAdminProcedure.input((v: unknown) => v as { orgId: string }).mutation(() => "org-admin-ok"),
|
|
45
49
|
});
|
|
46
50
|
|
|
47
51
|
const createCaller = createCallerFactory(appRouter);
|
|
@@ -240,4 +244,48 @@ describe("tRPC procedure builders", () => {
|
|
|
240
244
|
expect(await caller.orgAction({ orgId: "org-1" })).toBe("org-ok");
|
|
241
245
|
});
|
|
242
246
|
});
|
|
247
|
+
|
|
248
|
+
// -----------------------------------------------------------------------
|
|
249
|
+
// orgAdminProcedure
|
|
250
|
+
// -----------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
describe("orgAdminProcedure", () => {
|
|
253
|
+
it("rejects regular members", async () => {
|
|
254
|
+
const repo = makeMockRepo({
|
|
255
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "member", joinedAt: 0 }),
|
|
256
|
+
});
|
|
257
|
+
setTrpcOrgMemberRepo(repo);
|
|
258
|
+
|
|
259
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
260
|
+
await expect(caller.orgAdminAction({ orgId: "org-1" })).rejects.toThrow("Admin or owner role required");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("allows admin members", async () => {
|
|
264
|
+
const repo = makeMockRepo({
|
|
265
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "admin", joinedAt: 0 }),
|
|
266
|
+
});
|
|
267
|
+
setTrpcOrgMemberRepo(repo);
|
|
268
|
+
|
|
269
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
270
|
+
expect(await caller.orgAdminAction({ orgId: "org-1" })).toBe("org-admin-ok");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("allows owner members", async () => {
|
|
274
|
+
const repo = makeMockRepo({
|
|
275
|
+
findMember: vi.fn().mockResolvedValue({ id: "m1", orgId: "org-1", userId: "u1", role: "owner", joinedAt: 0 }),
|
|
276
|
+
});
|
|
277
|
+
setTrpcOrgMemberRepo(repo);
|
|
278
|
+
|
|
279
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
280
|
+
expect(await caller.orgAdminAction({ orgId: "org-1" })).toBe("org-admin-ok");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("rejects non-members", async () => {
|
|
284
|
+
const repo = makeMockRepo({ findMember: vi.fn().mockResolvedValue(null) });
|
|
285
|
+
setTrpcOrgMemberRepo(repo);
|
|
286
|
+
|
|
287
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
288
|
+
await expect(caller.orgAdminAction({ orgId: "org-1" })).rejects.toThrow("Not a member of this organization");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
243
291
|
});
|
package/src/trpc/init.ts
CHANGED
|
@@ -152,8 +152,25 @@ const isOrgMember = t.middleware(async ({ ctx, next, getRawInput }) => {
|
|
|
152
152
|
if (!member) {
|
|
153
153
|
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this organization" });
|
|
154
154
|
}
|
|
155
|
-
return next({
|
|
155
|
+
return next({
|
|
156
|
+
ctx: { user: ctx.user, tenantId: ctx.tenantId, orgRole: member.role as "owner" | "admin" | "member" },
|
|
157
|
+
});
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
/** Procedure that requires authentication + org membership (orgId must be in input). */
|
|
159
161
|
export const orgMemberProcedure = t.procedure.use(isAuthed).use(isOrgMember);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Middleware that enforces admin or owner role within an org.
|
|
165
|
+
* Must be chained after isOrgMember so ctx.orgRole is guaranteed.
|
|
166
|
+
*/
|
|
167
|
+
const isOrgAdmin = t.middleware(async ({ ctx, next }) => {
|
|
168
|
+
const role = (ctx as Record<string, unknown>).orgRole as string | undefined;
|
|
169
|
+
if (role === "member") {
|
|
170
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
171
|
+
}
|
|
172
|
+
return next({ ctx });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** Procedure that requires authentication + org admin/owner role (orgId must be in input). */
|
|
176
|
+
export const orgAdminProcedure = orgMemberProcedure.use(isOrgAdmin);
|