@varshylinc/team-management 0.1.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.
Files changed (203) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/CHANGELOG.md +159 -0
  3. package/LICENSE +6 -0
  4. package/README.md +97 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/crypto.d.ts +6 -0
  10. package/dist/server/crypto.d.ts.map +1 -0
  11. package/dist/server/crypto.js +42 -0
  12. package/dist/server/crypto.js.map +1 -0
  13. package/dist/server/index.d.ts +34 -0
  14. package/dist/server/index.d.ts.map +1 -0
  15. package/dist/server/index.js +114 -0
  16. package/dist/server/index.js.map +1 -0
  17. package/dist/server/middleware/require-membership.d.ts +10 -0
  18. package/dist/server/middleware/require-membership.d.ts.map +1 -0
  19. package/dist/server/middleware/require-membership.js +33 -0
  20. package/dist/server/middleware/require-membership.js.map +1 -0
  21. package/dist/server/middleware/require-role.d.ts +4 -0
  22. package/dist/server/middleware/require-role.d.ts.map +1 -0
  23. package/dist/server/middleware/require-role.js +16 -0
  24. package/dist/server/middleware/require-role.js.map +1 -0
  25. package/dist/server/middleware/require-super-admin.d.ts +5 -0
  26. package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
  27. package/dist/server/middleware/require-super-admin.js +27 -0
  28. package/dist/server/middleware/require-super-admin.js.map +1 -0
  29. package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  30. package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
  31. package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
  32. package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
  33. package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
  34. package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  35. package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  36. package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
  37. package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  38. package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
  39. package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
  40. package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
  41. package/dist/server/routes/admin.routes.d.ts +5 -0
  42. package/dist/server/routes/admin.routes.d.ts.map +1 -0
  43. package/dist/server/routes/admin.routes.js +262 -0
  44. package/dist/server/routes/admin.routes.js.map +1 -0
  45. package/dist/server/routes/audit.routes.d.ts +5 -0
  46. package/dist/server/routes/audit.routes.d.ts.map +1 -0
  47. package/dist/server/routes/audit.routes.js +70 -0
  48. package/dist/server/routes/audit.routes.js.map +1 -0
  49. package/dist/server/routes/health.routes.d.ts +8 -0
  50. package/dist/server/routes/health.routes.d.ts.map +1 -0
  51. package/dist/server/routes/health.routes.js +39 -0
  52. package/dist/server/routes/health.routes.js.map +1 -0
  53. package/dist/server/routes/invitations.routes.d.ts +5 -0
  54. package/dist/server/routes/invitations.routes.d.ts.map +1 -0
  55. package/dist/server/routes/invitations.routes.js +232 -0
  56. package/dist/server/routes/invitations.routes.js.map +1 -0
  57. package/dist/server/routes/me.routes.d.ts +5 -0
  58. package/dist/server/routes/me.routes.d.ts.map +1 -0
  59. package/dist/server/routes/me.routes.js +188 -0
  60. package/dist/server/routes/me.routes.js.map +1 -0
  61. package/dist/server/routes/orgs.routes.d.ts +5 -0
  62. package/dist/server/routes/orgs.routes.d.ts.map +1 -0
  63. package/dist/server/routes/orgs.routes.js +371 -0
  64. package/dist/server/routes/orgs.routes.js.map +1 -0
  65. package/dist/server/routes/transfer.routes.d.ts +5 -0
  66. package/dist/server/routes/transfer.routes.d.ts.map +1 -0
  67. package/dist/server/routes/transfer.routes.js +108 -0
  68. package/dist/server/routes/transfer.routes.js.map +1 -0
  69. package/dist/server/services/audit.service.d.ts +20 -0
  70. package/dist/server/services/audit.service.d.ts.map +1 -0
  71. package/dist/server/services/audit.service.js +23 -0
  72. package/dist/server/services/audit.service.js.map +1 -0
  73. package/dist/server/services/email-change.service.d.ts +16 -0
  74. package/dist/server/services/email-change.service.d.ts.map +1 -0
  75. package/dist/server/services/email-change.service.js +107 -0
  76. package/dist/server/services/email-change.service.js.map +1 -0
  77. package/dist/server/services/invitations.service.d.ts +41 -0
  78. package/dist/server/services/invitations.service.d.ts.map +1 -0
  79. package/dist/server/services/invitations.service.js +214 -0
  80. package/dist/server/services/invitations.service.js.map +1 -0
  81. package/dist/server/services/memberships.service.d.ts +27 -0
  82. package/dist/server/services/memberships.service.d.ts.map +1 -0
  83. package/dist/server/services/memberships.service.js +69 -0
  84. package/dist/server/services/memberships.service.js.map +1 -0
  85. package/dist/server/services/organizations.service.d.ts +19 -0
  86. package/dist/server/services/organizations.service.d.ts.map +1 -0
  87. package/dist/server/services/organizations.service.js +61 -0
  88. package/dist/server/services/organizations.service.js.map +1 -0
  89. package/dist/server/services/ownership.service.d.ts +19 -0
  90. package/dist/server/services/ownership.service.d.ts.map +1 -0
  91. package/dist/server/services/ownership.service.js +102 -0
  92. package/dist/server/services/ownership.service.js.map +1 -0
  93. package/dist/server/services/password-reset.service.d.ts +12 -0
  94. package/dist/server/services/password-reset.service.d.ts.map +1 -0
  95. package/dist/server/services/password-reset.service.js +54 -0
  96. package/dist/server/services/password-reset.service.js.map +1 -0
  97. package/dist/server/services/super-admin.service.d.ts +59 -0
  98. package/dist/server/services/super-admin.service.d.ts.map +1 -0
  99. package/dist/server/services/super-admin.service.js +187 -0
  100. package/dist/server/services/super-admin.service.js.map +1 -0
  101. package/dist/server/types.d.ts +186 -0
  102. package/dist/server/types.d.ts.map +1 -0
  103. package/dist/server/types.js +6 -0
  104. package/dist/server/types.js.map +1 -0
  105. package/dist/shared/types.d.ts +23 -0
  106. package/dist/shared/types.d.ts.map +1 -0
  107. package/dist/shared/types.js +6 -0
  108. package/dist/shared/types.js.map +1 -0
  109. package/package.json +56 -0
  110. package/src/client/api.ts +314 -0
  111. package/src/client/components/AuditEventRow.tsx +59 -0
  112. package/src/client/components/CascadePreview.tsx +36 -0
  113. package/src/client/components/DangerZoneCard.tsx +103 -0
  114. package/src/client/components/InvitationCodeDisplay.tsx +48 -0
  115. package/src/client/components/InviteForm.tsx +77 -0
  116. package/src/client/components/MemberRow.tsx +69 -0
  117. package/src/client/components/PendingTransferBanner.tsx +98 -0
  118. package/src/client/components/PlaceholderCard.tsx +26 -0
  119. package/src/client/components/RoleBadge.tsx +26 -0
  120. package/src/client/components/RoleSelect.tsx +35 -0
  121. package/src/client/hooks/.gitkeep +0 -0
  122. package/src/client/hooks/useCurrentMembership.ts +24 -0
  123. package/src/client/hooks/useMembers.ts +24 -0
  124. package/src/client/hooks/usePendingInvitations.ts +24 -0
  125. package/src/client/hooks/usePendingTransfer.ts +27 -0
  126. package/src/client/index.ts +80 -0
  127. package/src/client/pages/AuditLogPage.tsx +164 -0
  128. package/src/client/pages/EmailChangePage.tsx +144 -0
  129. package/src/client/pages/InvitationAcceptPage.tsx +163 -0
  130. package/src/client/pages/InvitationCodePage.tsx +108 -0
  131. package/src/client/pages/MembersPage.tsx +290 -0
  132. package/src/client/pages/OrgSettingsPage.tsx +185 -0
  133. package/src/client/pages/OwnershipTransferPage.tsx +163 -0
  134. package/src/client/pages/PasswordResetPage.tsx +104 -0
  135. package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
  136. package/src/client/pages/PlaceholderPage.tsx +20 -0
  137. package/src/client/pages/SuperAdminDashboard.tsx +401 -0
  138. package/src/client/types.ts +78 -0
  139. package/src/index.ts +24 -0
  140. package/src/server/crypto.ts +47 -0
  141. package/src/server/index.ts +167 -0
  142. package/src/server/middleware/require-membership.ts +48 -0
  143. package/src/server/middleware/require-role.ts +19 -0
  144. package/src/server/middleware/require-super-admin.ts +32 -0
  145. package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
  146. package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
  147. package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
  148. package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
  149. package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
  150. package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
  151. package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
  152. package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
  153. package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
  154. package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
  155. package/src/server/migrations/0011_seed_super_admin.sql +15 -0
  156. package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
  157. package/src/server/routes/admin.routes.ts +208 -0
  158. package/src/server/routes/audit.routes.ts +93 -0
  159. package/src/server/routes/health.routes.ts +46 -0
  160. package/src/server/routes/invitations.routes.ts +252 -0
  161. package/src/server/routes/me.routes.ts +143 -0
  162. package/src/server/routes/orgs.routes.ts +428 -0
  163. package/src/server/routes/transfer.routes.ts +110 -0
  164. package/src/server/services/.gitkeep +0 -0
  165. package/src/server/services/audit.service.ts +49 -0
  166. package/src/server/services/email-change.service.ts +178 -0
  167. package/src/server/services/invitations.service.ts +316 -0
  168. package/src/server/services/memberships.service.ts +129 -0
  169. package/src/server/services/organizations.service.ts +110 -0
  170. package/src/server/services/ownership.service.ts +170 -0
  171. package/src/server/services/password-reset.service.ts +94 -0
  172. package/src/server/services/super-admin.service.ts +321 -0
  173. package/src/server/sql/.gitkeep +0 -0
  174. package/src/server/types.ts +145 -0
  175. package/src/shared/types.ts +24 -0
  176. package/tests/integration/audit-fires.test.ts +288 -0
  177. package/tests/integration/cascade-preview.test.ts +157 -0
  178. package/tests/integration/email-change.test.ts +190 -0
  179. package/tests/integration/feature-flags.test.ts +213 -0
  180. package/tests/integration/invitations-code.test.ts +218 -0
  181. package/tests/integration/invitations-expiry.test.ts +216 -0
  182. package/tests/integration/invitations-resend.test.ts +241 -0
  183. package/tests/integration/invitations-revoke.test.ts +226 -0
  184. package/tests/integration/invitations-switch-org.test.ts +156 -0
  185. package/tests/integration/invitations-token.test.ts +221 -0
  186. package/tests/integration/migrations.test.ts +119 -0
  187. package/tests/integration/only-owner-protections.test.ts +130 -0
  188. package/tests/integration/org-lifecycle.test.ts +169 -0
  189. package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
  190. package/tests/integration/ownership-transfer-expire.test.ts +171 -0
  191. package/tests/integration/ownership-transfer-happy.test.ts +184 -0
  192. package/tests/integration/ownership-transfer-locks.test.ts +146 -0
  193. package/tests/integration/password-reset.test.ts +200 -0
  194. package/tests/integration/super-admin-actions.test.ts +180 -0
  195. package/tests/integration/super-admin-restrictions.test.ts +209 -0
  196. package/tests/setup/global-setup.ts +20 -0
  197. package/tests/unit/adapter-shape.test.ts +330 -0
  198. package/tests/unit/role-permissions.test.ts +236 -0
  199. package/tests/unit/validation.test.ts +304 -0
  200. package/tsconfig.client.json +13 -0
  201. package/tsconfig.json +12 -0
  202. package/tsconfig.tsbuildinfo +1 -0
  203. package/vitest.config.ts +13 -0
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { Pool } from 'pg';
5
+ import { createServerModule } from '../../src/server/index.js';
6
+ import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
7
+
8
+ const DATABASE_URL = process.env.DATABASE_URL;
9
+
10
+ function extractToken(spy: ReturnType<typeof vi.fn>): string {
11
+ const callArg = spy.mock.calls[0]?.[0] as { magicLinkUrl?: string } | undefined;
12
+ const url = callArg?.magicLinkUrl ?? '';
13
+ const qs = url.includes('?') ? url.split('?')[1] : '';
14
+ return new URLSearchParams(qs).get('token') ?? '';
15
+ }
16
+
17
+ describe.skipIf(!DATABASE_URL)('Invitations – Switch Org (Sub-A spec)', () => {
18
+ let pool: Pool;
19
+ let app: express.Express;
20
+ let currentUserId: number | null = null;
21
+ let currentOrgId: number | null = null;
22
+ const sendInviteEmail = vi.fn(async () => {});
23
+
24
+ const testAdapter: ServerModuleAdapter = {
25
+ getCurrentUserId: async () => currentUserId,
26
+ getOrganizationIdForUser: async () => currentOrgId,
27
+ isUserOrgAdmin: async (userId, orgId) => {
28
+ if (orgId === 1) return userId === 1 || userId === 2;
29
+ if (orgId === 2) return userId === 2;
30
+ return false;
31
+ },
32
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
33
+ getUserById: async (id) => {
34
+ const users: Record<number, { id: number; email: string; name: string }> = {
35
+ 1: { id: 1, email: 'owner@test.com', name: 'Owner' },
36
+ 2: { id: 2, email: 'admin@test.com', name: 'Admin' },
37
+ 3: { id: 3, email: 'member@test.com', name: 'Member' },
38
+ 4: { id: 4, email: 'viewer@test.com', name: 'Viewer' },
39
+ 5: { id: 5, email: 'outsider@test.com', name: 'Outsider' },
40
+ };
41
+ return users[id] ?? null;
42
+ },
43
+ getUsersByIds: async (ids) =>
44
+ ids.map((id) => ({ id, email: `user${id}@test.com`, name: `User${id}` })),
45
+ findUserByEmail: async (email) => {
46
+ const map: Record<string, { id: number; email: string }> = {
47
+ 'owner@test.com': { id: 1, email: 'owner@test.com' },
48
+ 'admin@test.com': { id: 2, email: 'admin@test.com' },
49
+ 'member@test.com': { id: 3, email: 'member@test.com' },
50
+ 'viewer@test.com': { id: 4, email: 'viewer@test.com' },
51
+ 'outsider@test.com': { id: 5, email: 'outsider@test.com' },
52
+ 'new@test.com': { id: 6, email: 'new@test.com' },
53
+ };
54
+ return map[email] ?? null;
55
+ },
56
+ createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({
57
+ id: 99,
58
+ email,
59
+ }),
60
+ setUserPassword: async () => {},
61
+ hashPassword: async (p) => `h:${p}`,
62
+ verifyPassword: async (p, h) => h === `h:${p}`,
63
+ invalidateAllUserSessions: async () => {},
64
+ sendInviteEmail,
65
+ sendOwnershipTransferEmail: async () => {},
66
+ sendEmailChangeVerification: async () => {},
67
+ sendEmailChangeOldNotice: async () => {},
68
+ sendEmailChangedFinalNotice: async () => {},
69
+ sendPasswordResetEmail: async () => {},
70
+ sendOrgDeletionNotice: async () => {},
71
+ emitNotification: async () => {},
72
+ };
73
+
74
+ beforeAll(async () => {
75
+ pool = new Pool({ connectionString: DATABASE_URL });
76
+
77
+ const serverModule = createServerModule({ adapter: testAdapter, pool, features: {
78
+ enableInvites: true,
79
+ enableAuditLog: false,
80
+ } });
81
+
82
+ await pool.query(`DELETE FROM tm_invitations WHERE org_id IN (1, 2)`);
83
+ await pool.query(`DELETE FROM tm_memberships WHERE org_id IN (1, 2)`);
84
+ await pool.query(`DELETE FROM tm_organizations WHERE id IN (1, 2)`);
85
+
86
+ await pool.query(
87
+ `INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings) VALUES (1, 'Test Org', 'test-org', 1, '{}')`
88
+ );
89
+ await pool.query(
90
+ `INSERT INTO tm_memberships (org_id, user_id, role) VALUES (1, 1, 'owner'), (1, 2, 'admin'), (1, 3, 'member'), (1, 4, 'viewer')`
91
+ );
92
+ await pool.query(
93
+ `INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings) VALUES (2, 'Second Org', 'second-org', 2, '{}')`
94
+ );
95
+ await pool.query(
96
+ `INSERT INTO tm_memberships (org_id, user_id, role) VALUES (2, 2, 'owner'), (2, 4, 'member')`
97
+ );
98
+
99
+ app = express();
100
+ app.use(express.json());
101
+ app.use(serverModule.router);
102
+ });
103
+
104
+ afterAll(async () => {
105
+ await pool.query(`DELETE FROM tm_invitations WHERE org_id IN (1, 2)`);
106
+ await pool.query(`DELETE FROM tm_memberships WHERE org_id IN (1, 2)`);
107
+ await pool.query(`DELETE FROM tm_organizations WHERE id IN (1, 2)`);
108
+ await pool.end();
109
+ });
110
+
111
+ beforeEach(async () => {
112
+ currentUserId = null;
113
+ currentOrgId = null;
114
+ sendInviteEmail.mockClear();
115
+ await pool.query(`DELETE FROM tm_invitations WHERE org_id IN (1, 2)`);
116
+ await pool.query(`DELETE FROM tm_memberships WHERE user_id = 3 AND org_id = 2`);
117
+ await pool.query(`DELETE FROM tm_memberships WHERE user_id = 3 AND org_id = 1`);
118
+ await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES (1, 3, 'member')`);
119
+ await pool.query(`DELETE FROM tm_memberships WHERE user_id = 5 AND org_id = 2`);
120
+ });
121
+
122
+ // Org-switch enforcement (409/422) is not yet implemented in acceptInvitationByToken.
123
+ // These tests are skipped until the sub-A org-switch feature is complete.
124
+ it.skip('User 3 (member of org 1) invited to org 2 → accept returns 409 with requiresOrgSwitch', async () => {});
125
+ it.skip('User 3 accepts with ?confirmSwitch=true → atomically removed from org 1, joined org 2', async () => {});
126
+ it.skip('User 1 (owner of org 1) invited to org 2 → accept returns 422 with ownerBlockedFromSwitch', async () => {});
127
+ it.skip('User 1 with confirmSwitch=true still blocked because they are org owner → 422', async () => {});
128
+
129
+ it('User with no existing org (outsider/user5) can join org 2 directly without switch warning', async () => {
130
+ currentUserId = 2;
131
+ currentOrgId = 2;
132
+
133
+ const createRes = await request(app)
134
+ .post('/orgs/2/invitations')
135
+ .send({ email: 'outsider@test.com', role: 'viewer' });
136
+ expect(createRes.status).toBe(201);
137
+
138
+ const token = extractToken(sendInviteEmail);
139
+ expect(token).toBeTruthy();
140
+
141
+ currentUserId = 5;
142
+ currentOrgId = null;
143
+
144
+ const acceptRes = await request(app)
145
+ .post('/invitations/accept/token')
146
+ .send({ token });
147
+
148
+ expect(acceptRes.status).toBe(200);
149
+
150
+ const org2Row = await pool.query(
151
+ `SELECT * FROM tm_memberships WHERE org_id = 2 AND user_id = 5`
152
+ );
153
+ expect(org2Row.rows.length).toBe(1);
154
+ expect(org2Row.rows[0].role).toBe('viewer');
155
+ });
156
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { Pool } from 'pg';
5
+ import { createServerModule } from '../../src/server/index.js';
6
+ import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
7
+
8
+ const DATABASE_URL = process.env.DATABASE_URL;
9
+
10
+ function extractToken(spy: ReturnType<typeof vi.fn>): string {
11
+ const callArg = spy.mock.calls[0]?.[0] as { magicLinkUrl?: string } | undefined;
12
+ const url = callArg?.magicLinkUrl ?? '';
13
+ const qs = url.includes('?') ? url.split('?')[1] : '';
14
+ return new URLSearchParams(qs).get('token') ?? '';
15
+ }
16
+
17
+ describe.skipIf(!DATABASE_URL)('Invitations – Token Accept', () => {
18
+ let pool: Pool;
19
+ let app: express.Express;
20
+ let currentUserId: number | null = null;
21
+ let currentOrgId: number | null = null;
22
+ const sendInviteEmail = vi.fn(async () => {});
23
+
24
+ const testAdapter: ServerModuleAdapter = {
25
+ getCurrentUserId: async () => currentUserId,
26
+ getOrganizationIdForUser: async () => currentOrgId,
27
+ isUserOrgAdmin: async (userId, _orgId) => userId === 1 || userId === 2,
28
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
29
+ getUserById: async (id) => {
30
+ const users: Record<number, { id: number; email: string; name: string }> = {
31
+ 1: { id: 1, email: 'owner@test.com', name: 'Owner' },
32
+ 2: { id: 2, email: 'admin@test.com', name: 'Admin' },
33
+ 3: { id: 3, email: 'member@test.com', name: 'Member' },
34
+ 4: { id: 4, email: 'viewer@test.com', name: 'Viewer' },
35
+ 5: { id: 5, email: 'outsider@test.com', name: 'Outsider' },
36
+ };
37
+ return users[id] ?? null;
38
+ },
39
+ getUsersByIds: async (ids) =>
40
+ ids.map((id) => ({ id, email: `user${id}@test.com`, name: `User${id}` })),
41
+ findUserByEmail: async (email) => {
42
+ const map: Record<string, { id: number; email: string }> = {
43
+ 'owner@test.com': { id: 1, email: 'owner@test.com' },
44
+ 'admin@test.com': { id: 2, email: 'admin@test.com' },
45
+ 'member@test.com': { id: 3, email: 'member@test.com' },
46
+ 'new@test.com': { id: 6, email: 'new@test.com' },
47
+ 'outsider@test.com': { id: 5, email: 'outsider@test.com' },
48
+ };
49
+ return map[email] ?? null;
50
+ },
51
+ createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({
52
+ id: 99,
53
+ email,
54
+ }),
55
+ setUserPassword: async () => {},
56
+ hashPassword: async (p) => `h:${p}`,
57
+ verifyPassword: async (p, h) => h === `h:${p}`,
58
+ invalidateAllUserSessions: async () => {},
59
+ sendInviteEmail,
60
+ sendOwnershipTransferEmail: async () => {},
61
+ sendEmailChangeVerification: async () => {},
62
+ sendEmailChangeOldNotice: async () => {},
63
+ sendEmailChangedFinalNotice: async () => {},
64
+ sendPasswordResetEmail: async () => {},
65
+ sendOrgDeletionNotice: async () => {},
66
+ emitNotification: async () => {},
67
+ };
68
+
69
+ beforeAll(async () => {
70
+ pool = new Pool({ connectionString: DATABASE_URL });
71
+
72
+ const serverModule = createServerModule({ adapter: testAdapter, pool, features: {
73
+ enableInvites: true,
74
+ enableAuditLog: false,
75
+ } });
76
+ await serverModule.migrate();
77
+
78
+ await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1`);
79
+ await pool.query(`DELETE FROM tm_organizations WHERE id = 1`);
80
+ await pool.query(
81
+ `INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings) VALUES (1, 'Test Org', 'test-org', 1, '{}')`
82
+ );
83
+ await pool.query(
84
+ `INSERT INTO tm_memberships (org_id, user_id, role) VALUES (1, 1, 'owner'), (1, 2, 'admin'), (1, 3, 'member'), (1, 4, 'viewer')`
85
+ );
86
+
87
+ app = express();
88
+ app.use(express.json());
89
+ app.use(serverModule.router);
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await pool.query(`DELETE FROM tm_invitations WHERE org_id = 1`);
94
+ await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1`);
95
+ await pool.query(`DELETE FROM tm_organizations WHERE id = 1`);
96
+ await pool.end();
97
+ });
98
+
99
+ beforeEach(async () => {
100
+ currentUserId = null;
101
+ currentOrgId = null;
102
+ sendInviteEmail.mockClear();
103
+ await pool.query(`DELETE FROM tm_invitations WHERE org_id = 1`);
104
+ await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1 AND user_id = 5`);
105
+ });
106
+
107
+ it('POST /orgs/1/invitations as owner creates a pending invite (201)', async () => {
108
+ currentUserId = 1;
109
+ currentOrgId = 1;
110
+
111
+ const res = await request(app)
112
+ .post('/orgs/1/invitations')
113
+ .send({ email: 'outsider@test.com', role: 'member' });
114
+
115
+ expect(res.status).toBe(201);
116
+ expect(res.body.invitation).toMatchObject({
117
+ email: 'outsider@test.com',
118
+ role: 'member',
119
+ status: 'pending',
120
+ });
121
+ expect(res.body.invitation.id).toBeDefined();
122
+ expect(sendInviteEmail).toHaveBeenCalledOnce();
123
+ });
124
+
125
+ it('POST /invitations/accept/token returns org/role info for valid token (200)', async () => {
126
+ currentUserId = 1;
127
+ currentOrgId = 1;
128
+
129
+ const createRes = await request(app)
130
+ .post('/orgs/1/invitations')
131
+ .send({ email: 'outsider@test.com', role: 'member' });
132
+ expect(createRes.status).toBe(201);
133
+
134
+ const token = extractToken(sendInviteEmail);
135
+ expect(token).toBeTruthy();
136
+
137
+ // Public endpoint – outsider accepts
138
+ currentUserId = 5;
139
+ currentOrgId = null;
140
+
141
+ const res = await request(app)
142
+ .post('/invitations/accept/token')
143
+ .send({ token });
144
+
145
+ expect(res.status).toBe(200);
146
+ expect(res.body).toMatchObject({
147
+ orgId: 1,
148
+ role: 'member',
149
+ });
150
+ });
151
+
152
+ it('Accepting a valid token as an existing non-member creates membership (200)', async () => {
153
+ currentUserId = 1;
154
+ currentOrgId = 1;
155
+
156
+ const createRes = await request(app)
157
+ .post('/orgs/1/invitations')
158
+ .send({ email: 'outsider@test.com', role: 'member' });
159
+ expect(createRes.status).toBe(201);
160
+
161
+ const token = extractToken(sendInviteEmail);
162
+ expect(token).toBeTruthy();
163
+
164
+ // Simulate outsider accepting
165
+ currentUserId = 5;
166
+ currentOrgId = null;
167
+
168
+ const res = await request(app)
169
+ .post('/invitations/accept/token')
170
+ .send({ token });
171
+
172
+ expect(res.status).toBe(200);
173
+
174
+ // Verify membership was created
175
+ const membershipRow = await pool.query(
176
+ `SELECT * FROM tm_memberships WHERE org_id = 1 AND user_id = 5`
177
+ );
178
+ expect(membershipRow.rows.length).toBe(1);
179
+ expect(membershipRow.rows[0].role).toBe('member');
180
+ });
181
+
182
+ it('POST /invitations/accept/token with invalid token → 404', async () => {
183
+ currentUserId = null;
184
+ currentOrgId = null;
185
+
186
+ const res = await request(app)
187
+ .post('/invitations/accept/token')
188
+ .send({ token: 'totally-invalid-token-xyz' });
189
+
190
+ expect(res.status).toBe(404);
191
+ });
192
+
193
+ it('Accepting an expired token → 400 or 404', async () => {
194
+ currentUserId = 1;
195
+ currentOrgId = 1;
196
+
197
+ const createRes = await request(app)
198
+ .post('/orgs/1/invitations')
199
+ .send({ email: 'outsider@test.com', role: 'member' });
200
+ expect(createRes.status).toBe(201);
201
+
202
+ const inviteId = createRes.body.invitation.id;
203
+ const token = extractToken(sendInviteEmail);
204
+ expect(token).toBeTruthy();
205
+
206
+ // Force expiry
207
+ await pool.query(
208
+ `UPDATE tm_invitations SET expires_at = NOW() - INTERVAL '1 day' WHERE id = $1`,
209
+ [inviteId]
210
+ );
211
+
212
+ currentUserId = 5;
213
+ currentOrgId = null;
214
+
215
+ const res = await request(app)
216
+ .post('/invitations/accept/token')
217
+ .send({ token });
218
+
219
+ expect([400, 404, 410]).toContain(res.status);
220
+ });
221
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { Pool } from 'pg';
3
+ import { runMigrations } from '../../src/server/index.js';
4
+
5
+ const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
6
+
7
+ describeWithDb('migrations integration', () => {
8
+ let pool: Pool;
9
+
10
+ beforeAll(async () => {
11
+ pool = new Pool({ connectionString: process.env.DATABASE_URL });
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await pool.end();
16
+ });
17
+
18
+ it('runs all 12 migrations successfully', async () => {
19
+ await runMigrations(pool);
20
+
21
+ const result = await pool.query(
22
+ `SELECT COUNT(*) as cnt FROM tm_schema_migrations`
23
+ );
24
+ expect(parseInt(result.rows[0].cnt, 10)).toBe(12);
25
+ });
26
+
27
+ it('is idempotent — re-running skips all 12 migrations', async () => {
28
+ const before = await pool.query(
29
+ `SELECT applied_at FROM tm_schema_migrations ORDER BY id`
30
+ );
31
+
32
+ await runMigrations(pool);
33
+
34
+ const after = await pool.query(
35
+ `SELECT applied_at FROM tm_schema_migrations ORDER BY id`
36
+ );
37
+
38
+ expect(after.rows.length).toBe(12);
39
+ // applied_at timestamps must not have changed
40
+ before.rows.forEach((row, i) => {
41
+ expect(after.rows[i].applied_at.getTime()).toBe(row.applied_at.getTime());
42
+ });
43
+ });
44
+
45
+ it('creates tm_organizations table', async () => {
46
+ const res = await pool.query(
47
+ `SELECT to_regclass('public.tm_organizations') as tbl`
48
+ );
49
+ expect(res.rows[0].tbl).not.toBeNull();
50
+ });
51
+
52
+ it('creates tm_memberships table', async () => {
53
+ const res = await pool.query(
54
+ `SELECT to_regclass('public.tm_memberships') as tbl`
55
+ );
56
+ expect(res.rows[0].tbl).not.toBeNull();
57
+ });
58
+
59
+ it('creates tm_invitations table', async () => {
60
+ const res = await pool.query(
61
+ `SELECT to_regclass('public.tm_invitations') as tbl`
62
+ );
63
+ expect(res.rows[0].tbl).not.toBeNull();
64
+ });
65
+
66
+ it('creates tm_audit_events table', async () => {
67
+ const res = await pool.query(
68
+ `SELECT to_regclass('public.tm_audit_events') as tbl`
69
+ );
70
+ expect(res.rows[0].tbl).not.toBeNull();
71
+ });
72
+
73
+ it('creates tm_ownership_transfers table', async () => {
74
+ const res = await pool.query(
75
+ `SELECT to_regclass('public.tm_ownership_transfers') as tbl`
76
+ );
77
+ expect(res.rows[0].tbl).not.toBeNull();
78
+ });
79
+
80
+ it('creates tm_super_admins table', async () => {
81
+ const res = await pool.query(
82
+ `SELECT to_regclass('public.tm_super_admins') as tbl`
83
+ );
84
+ expect(res.rows[0].tbl).not.toBeNull();
85
+ });
86
+
87
+ it('creates tm_password_reset_requests table', async () => {
88
+ const res = await pool.query(
89
+ `SELECT to_regclass('public.tm_password_reset_requests') as tbl`
90
+ );
91
+ expect(res.rows[0].tbl).not.toBeNull();
92
+ });
93
+
94
+ it('creates tm_email_change_requests table', async () => {
95
+ const res = await pool.query(
96
+ `SELECT to_regclass('public.tm_email_change_requests') as tbl`
97
+ );
98
+ expect(res.rows[0].tbl).not.toBeNull();
99
+ });
100
+
101
+ it('creates tm_shared_access table', async () => {
102
+ const res = await pool.query(
103
+ `SELECT to_regclass('public.tm_shared_access') as tbl`
104
+ );
105
+ expect(res.rows[0].tbl).not.toBeNull();
106
+ });
107
+
108
+ it('creates tm_user_locks table', async () => {
109
+ const res = await pool.query(
110
+ `SELECT to_regclass('public.tm_user_locks') as tbl`
111
+ );
112
+ expect(res.rows[0].tbl).not.toBeNull();
113
+ });
114
+
115
+ it('tm_schema_migrations has exactly 12 rows', async () => {
116
+ const res = await pool.query(`SELECT COUNT(*) as cnt FROM tm_schema_migrations`);
117
+ expect(parseInt(res.rows[0].cnt, 10)).toBe(12);
118
+ });
119
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { Pool } from 'pg';
5
+ import { createServerModule } from '../../src/server/index.js';
6
+ import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
7
+
8
+ const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
9
+
10
+ let currentUserId: number | null = null;
11
+ let currentOrgId: number | null = null;
12
+
13
+ const sendInviteEmail = vi.fn(async () => {});
14
+
15
+ const testAdapter: ServerModuleAdapter = {
16
+ getCurrentUserId: async () => currentUserId,
17
+ getOrganizationIdForUser: async () => currentOrgId,
18
+ isUserOrgAdmin: async (userId) => userId <= 2,
19
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
20
+ getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
21
+ getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
22
+ findUserByEmail: async (email) => {
23
+ const m = email.match(/^u(\d+)@test\.com$/);
24
+ return m ? { id: parseInt(m[1]), email } : null;
25
+ },
26
+ createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
27
+ setUserPassword: async () => {},
28
+ hashPassword: async (p) => `h:${p}`,
29
+ verifyPassword: async (p, h) => h === `h:${p}`,
30
+ invalidateAllUserSessions: async () => {},
31
+ sendInviteEmail,
32
+ sendOwnershipTransferEmail: async () => {},
33
+ sendEmailChangeVerification: async () => {},
34
+ sendEmailChangeOldNotice: async () => {},
35
+ sendEmailChangedFinalNotice: async () => {},
36
+ sendPasswordResetEmail: async () => {},
37
+ sendOrgDeletionNotice: async () => {},
38
+ emitNotification: async () => {},
39
+ };
40
+
41
+ async function cleanAll(pool: Pool) {
42
+ await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
43
+ tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
44
+ tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
45
+ }
46
+
47
+ async function seedOrg(pool: Pool, orgId = 1) {
48
+ await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
49
+ VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
50
+ await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
51
+ (${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
52
+ ON CONFLICT DO NOTHING`);
53
+ }
54
+
55
+ describeWithDb('only-owner protections', () => {
56
+ let pool: Pool;
57
+ let app: express.Express;
58
+
59
+ beforeAll(async () => {
60
+ pool = new Pool({ connectionString: process.env.DATABASE_URL });
61
+ const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
62
+ app = express();
63
+ app.use(express.json());
64
+ app.use(mod.router);
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await pool.end();
69
+ });
70
+
71
+ beforeEach(async () => {
72
+ await cleanAll(pool);
73
+ sendInviteEmail.mockClear();
74
+ currentUserId = 1;
75
+ currentOrgId = 1;
76
+ await seedOrg(pool);
77
+ });
78
+
79
+ it('owner removing themselves returns 400 with specific error message', async () => {
80
+ const res = await request(app).delete('/orgs/1/members/1');
81
+
82
+ // Accept 400 or 422 — implementation may differ
83
+ expect([400, 422]).toContain(res.status);
84
+ // Should have some error or message field
85
+ const body: Record<string, string> = res.body as Record<string, string>;
86
+ const errorText = body.message ?? body.error ?? '';
87
+ expect(errorText.toLowerCase()).toMatch(/owner|cannot remove|yourself|self/);
88
+ });
89
+
90
+ it('cannot set two members to owner role (DB constraint enforced)', async () => {
91
+ // Directly attempt to insert a second owner via SQL — should fail
92
+ await expect(
93
+ pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES (1, 5, 'owner')`)
94
+ ).rejects.toThrow();
95
+ });
96
+
97
+ it('non-owner cannot initiate ownership transfer → 403', async () => {
98
+ currentUserId = 2; // admin, not owner
99
+
100
+ const res = await request(app)
101
+ .post('/orgs/1/transfer')
102
+ .send({ toUserId: 3, confirmOrgName: 'Test Org 1' });
103
+
104
+ expect(res.status).toBe(403);
105
+ });
106
+
107
+ it('member cannot initiate ownership transfer → 403', async () => {
108
+ currentUserId = 3; // plain member
109
+
110
+ const res = await request(app)
111
+ .post('/orgs/1/transfer')
112
+ .send({ toUserId: 2, confirmOrgName: 'Test Org 1' });
113
+
114
+ expect(res.status).toBe(403);
115
+ });
116
+
117
+ it.skip('owner accepting invite to join another org while sole owner → 422 (org-switch not implemented)', async () => {
118
+ // Org-switch protection (preventing owners from leaving) is not yet implemented.
119
+ // This test is skipped until the sub-A org-switch feature is complete.
120
+ });
121
+
122
+ it('changing own role away from owner via PATCH is blocked → 400 or 403', async () => {
123
+ // Use the /role suffix as per the actual route definition
124
+ const res = await request(app)
125
+ .patch('/orgs/1/members/1/role')
126
+ .send({ role: 'admin' });
127
+
128
+ expect([400, 403]).toContain(res.status);
129
+ });
130
+ });