@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,236 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { roleAtLeast } from '../../src/server/types.js';
3
+ import type { OrgRole } from '../../src/server/types.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Permission matrix from spec
7
+ //
8
+ // ACTION | OWNER | ADMIN | MEMBER | VIEWER
9
+ // ------------------------------------|-------|-------|--------|--------
10
+ // View org info | ✓ | ✓ | ✓ | ✓
11
+ // List members | ✓ | ✓ | ✓ | ✓
12
+ // View audit log | ✓ | ✓ | ✗ | ✗
13
+ // Edit org settings (name/slug) | ✓ | ✓ | ✗ | ✗
14
+ // Invite member | ✓ | ✓ | ✗ | ✗
15
+ // Revoke pending invite | ✓ | ✓ | ✗ | ✗
16
+ // Change member role (not owner) | ✓ | ✓ | ✗ | ✗
17
+ // Change another admin's role | ✓ | ✗ | ✗ | ✗
18
+ // Remove member (not owner) | ✓ | ✓ | ✗ | ✗
19
+ // Remove an admin | ✓ | ✗ | ✗ | ✗
20
+ // Initiate ownership transfer | ✓ | ✗ | ✗ | ✗
21
+ // Delete org | ✓ | ✗ | ✗ | ✗
22
+ // Change own email | ✓ | ✓ | ✓ | ✓
23
+ // Reset own password | ✓ | ✓ | ✓ | ✓
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const ALL_ROLES: OrgRole[] = ['owner', 'admin', 'member', 'viewer'];
27
+
28
+ // Helper: given a minimum role, returns whether each role in ALL_ROLES can perform the action.
29
+ function canDo(minRole: OrgRole): Record<OrgRole, boolean> {
30
+ return {
31
+ owner: roleAtLeast('owner', minRole),
32
+ admin: roleAtLeast('admin', minRole),
33
+ member: roleAtLeast('member', minRole),
34
+ viewer: roleAtLeast('viewer', minRole),
35
+ };
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // View org info — minimum role: viewer (all roles allowed)
40
+ // ---------------------------------------------------------------------------
41
+ describe('View org info', () => {
42
+ const perms = canDo('viewer');
43
+
44
+ it('owner can view org info', () => expect(perms.owner).toBe(true));
45
+ it('admin can view org info', () => expect(perms.admin).toBe(true));
46
+ it('member can view org info', () => expect(perms.member).toBe(true));
47
+ it('viewer can view org info', () => expect(perms.viewer).toBe(true));
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // List members — minimum role: viewer (all roles allowed)
52
+ // ---------------------------------------------------------------------------
53
+ describe('List members', () => {
54
+ const perms = canDo('viewer');
55
+
56
+ it('owner can list members', () => expect(perms.owner).toBe(true));
57
+ it('admin can list members', () => expect(perms.admin).toBe(true));
58
+ it('member can list members', () => expect(perms.member).toBe(true));
59
+ it('viewer can list members', () => expect(perms.viewer).toBe(true));
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // View audit log — minimum role: admin
64
+ // ---------------------------------------------------------------------------
65
+ describe('View audit log', () => {
66
+ const perms = canDo('admin');
67
+
68
+ it('owner can view audit log', () => expect(perms.owner).toBe(true));
69
+ it('admin can view audit log', () => expect(perms.admin).toBe(true));
70
+ it('member cannot view audit log', () => expect(perms.member).toBe(false));
71
+ it('viewer cannot view audit log', () => expect(perms.viewer).toBe(false));
72
+ });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Edit org settings (name/slug) — minimum role: admin
76
+ // ---------------------------------------------------------------------------
77
+ describe('Edit org settings (name/slug)', () => {
78
+ const perms = canDo('admin');
79
+
80
+ it('owner can edit org settings', () => expect(perms.owner).toBe(true));
81
+ it('admin can edit org settings', () => expect(perms.admin).toBe(true));
82
+ it('member cannot edit org settings', () => expect(perms.member).toBe(false));
83
+ it('viewer cannot edit org settings', () => expect(perms.viewer).toBe(false));
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Invite member — minimum role: admin
88
+ // ---------------------------------------------------------------------------
89
+ describe('Invite member', () => {
90
+ const perms = canDo('admin');
91
+
92
+ it('owner can invite member', () => expect(perms.owner).toBe(true));
93
+ it('admin can invite member', () => expect(perms.admin).toBe(true));
94
+ it('member cannot invite member', () => expect(perms.member).toBe(false));
95
+ it('viewer cannot invite member', () => expect(perms.viewer).toBe(false));
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Revoke pending invite — minimum role: admin
100
+ // ---------------------------------------------------------------------------
101
+ describe('Revoke pending invite', () => {
102
+ const perms = canDo('admin');
103
+
104
+ it('owner can revoke pending invite', () => expect(perms.owner).toBe(true));
105
+ it('admin can revoke pending invite', () => expect(perms.admin).toBe(true));
106
+ it('member cannot revoke pending invite', () => expect(perms.member).toBe(false));
107
+ it('viewer cannot revoke pending invite', () => expect(perms.viewer).toBe(false));
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Change member role (not owner) — minimum role: admin
112
+ // ---------------------------------------------------------------------------
113
+ describe('Change member role (not owner)', () => {
114
+ const perms = canDo('admin');
115
+
116
+ it('owner can change member role', () => expect(perms.owner).toBe(true));
117
+ it('admin can change member role', () => expect(perms.admin).toBe(true));
118
+ it('member cannot change member role', () => expect(perms.member).toBe(false));
119
+ it('viewer cannot change member role', () => expect(perms.viewer).toBe(false));
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Change another admin's role — minimum role: owner
124
+ // ---------------------------------------------------------------------------
125
+ describe("Change another admin's role", () => {
126
+ const perms = canDo('owner');
127
+
128
+ it("owner can change another admin's role", () => expect(perms.owner).toBe(true));
129
+ it("admin cannot change another admin's role", () => expect(perms.admin).toBe(false));
130
+ it("member cannot change another admin's role", () => expect(perms.member).toBe(false));
131
+ it("viewer cannot change another admin's role", () => expect(perms.viewer).toBe(false));
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Remove member (not owner) — minimum role: admin
136
+ // ---------------------------------------------------------------------------
137
+ describe('Remove member (not owner)', () => {
138
+ const perms = canDo('admin');
139
+
140
+ it('owner can remove member', () => expect(perms.owner).toBe(true));
141
+ it('admin can remove member', () => expect(perms.admin).toBe(true));
142
+ it('member cannot remove member', () => expect(perms.member).toBe(false));
143
+ it('viewer cannot remove member', () => expect(perms.viewer).toBe(false));
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Remove an admin — minimum role: owner
148
+ // ---------------------------------------------------------------------------
149
+ describe('Remove an admin', () => {
150
+ const perms = canDo('owner');
151
+
152
+ it('owner can remove an admin', () => expect(perms.owner).toBe(true));
153
+ it('admin cannot remove an admin', () => expect(perms.admin).toBe(false));
154
+ it('member cannot remove an admin', () => expect(perms.member).toBe(false));
155
+ it('viewer cannot remove an admin', () => expect(perms.viewer).toBe(false));
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Initiate ownership transfer — minimum role: owner
160
+ // ---------------------------------------------------------------------------
161
+ describe('Initiate ownership transfer', () => {
162
+ const perms = canDo('owner');
163
+
164
+ it('owner can initiate ownership transfer', () => expect(perms.owner).toBe(true));
165
+ it('admin cannot initiate ownership transfer', () => expect(perms.admin).toBe(false));
166
+ it('member cannot initiate ownership transfer', () => expect(perms.member).toBe(false));
167
+ it('viewer cannot initiate ownership transfer', () => expect(perms.viewer).toBe(false));
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Delete org — minimum role: owner
172
+ // ---------------------------------------------------------------------------
173
+ describe('Delete org', () => {
174
+ const perms = canDo('owner');
175
+
176
+ it('owner can delete org', () => expect(perms.owner).toBe(true));
177
+ it('admin cannot delete org', () => expect(perms.admin).toBe(false));
178
+ it('member cannot delete org', () => expect(perms.member).toBe(false));
179
+ it('viewer cannot delete org', () => expect(perms.viewer).toBe(false));
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Change own email — minimum role: viewer (all roles allowed)
184
+ // ---------------------------------------------------------------------------
185
+ describe('Change own email', () => {
186
+ const perms = canDo('viewer');
187
+
188
+ it('owner can change own email', () => expect(perms.owner).toBe(true));
189
+ it('admin can change own email', () => expect(perms.admin).toBe(true));
190
+ it('member can change own email', () => expect(perms.member).toBe(true));
191
+ it('viewer can change own email', () => expect(perms.viewer).toBe(true));
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Reset own password — minimum role: viewer (all roles allowed)
196
+ // ---------------------------------------------------------------------------
197
+ describe('Reset own password', () => {
198
+ const perms = canDo('viewer');
199
+
200
+ it('owner can reset own password', () => expect(perms.owner).toBe(true));
201
+ it('admin can reset own password', () => expect(perms.admin).toBe(true));
202
+ it('member can reset own password', () => expect(perms.member).toBe(true));
203
+ it('viewer can reset own password', () => expect(perms.viewer).toBe(true));
204
+ });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Cross-cutting: exhaustive matrix spot-checks
208
+ // ---------------------------------------------------------------------------
209
+ describe('Full permission matrix — exhaustive spot-checks', () => {
210
+ it('owner has highest privilege (passes all checks)', () => {
211
+ for (const role of ALL_ROLES) {
212
+ expect(roleAtLeast('owner', role)).toBe(true);
213
+ }
214
+ });
215
+
216
+ it('viewer has lowest privilege (only passes viewer check)', () => {
217
+ expect(roleAtLeast('viewer', 'viewer')).toBe(true);
218
+ expect(roleAtLeast('viewer', 'member')).toBe(false);
219
+ expect(roleAtLeast('viewer', 'admin')).toBe(false);
220
+ expect(roleAtLeast('viewer', 'owner')).toBe(false);
221
+ });
222
+
223
+ it('admin passes admin and below, fails owner', () => {
224
+ expect(roleAtLeast('admin', 'viewer')).toBe(true);
225
+ expect(roleAtLeast('admin', 'member')).toBe(true);
226
+ expect(roleAtLeast('admin', 'admin')).toBe(true);
227
+ expect(roleAtLeast('admin', 'owner')).toBe(false);
228
+ });
229
+
230
+ it('member passes member and viewer, fails admin and owner', () => {
231
+ expect(roleAtLeast('member', 'viewer')).toBe(true);
232
+ expect(roleAtLeast('member', 'member')).toBe(true);
233
+ expect(roleAtLeast('member', 'admin')).toBe(false);
234
+ expect(roleAtLeast('member', 'owner')).toBe(false);
235
+ });
236
+ });
@@ -0,0 +1,304 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Pure validation functions — no src imports needed.
5
+ // These mirror the validation logic that the package enforces.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ // Slug: lowercase alphanumeric + hyphens, 3-50 chars, no leading/trailing hyphens.
9
+ function isValidSlug(slug: string): boolean {
10
+ if (slug.length < 3 || slug.length > 50) return false;
11
+ // Must start and end with alphanumeric, inner chars can include hyphens.
12
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]{1}$/.test(slug) && slug.length >= 3;
13
+ }
14
+
15
+ // Email: basic RFC-ish check — local@domain.tld
16
+ function isValidEmail(email: string): boolean {
17
+ if (!email) return false;
18
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
19
+ }
20
+
21
+ // OrgRole: exactly one of the four valid roles.
22
+ function isValidOrgRole(role: string): boolean {
23
+ return ['owner', 'admin', 'member', 'viewer'].includes(role);
24
+ }
25
+
26
+ // Token: hex string, exactly 64 characters.
27
+ function isValidToken(token: string): boolean {
28
+ return /^[0-9a-f]{64}$/.test(token);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Slug validation
33
+ // ---------------------------------------------------------------------------
34
+ describe('Slug validation', () => {
35
+ describe('valid slugs', () => {
36
+ it('"demo-co" is valid', () => {
37
+ expect(isValidSlug('demo-co')).toBe(true);
38
+ });
39
+
40
+ it('"acme" is valid', () => {
41
+ expect(isValidSlug('acme')).toBe(true);
42
+ });
43
+
44
+ it('"my-org-123" is valid', () => {
45
+ expect(isValidSlug('my-org-123')).toBe(true);
46
+ });
47
+
48
+ it('exactly 3 chars "abc" is valid (lower boundary)', () => {
49
+ expect(isValidSlug('abc')).toBe(true);
50
+ });
51
+
52
+ it('exactly 50 chars is valid (upper boundary)', () => {
53
+ const slug = 'a' + 'b'.repeat(48) + 'c'; // 50 chars
54
+ expect(slug.length).toBe(50);
55
+ expect(isValidSlug(slug)).toBe(true);
56
+ });
57
+
58
+ it('alphanumeric with numbers "org123" is valid', () => {
59
+ expect(isValidSlug('org123')).toBe(true);
60
+ });
61
+
62
+ it('multiple hyphens in middle "my-big-org-2024" is valid', () => {
63
+ expect(isValidSlug('my-big-org-2024')).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('invalid slugs', () => {
68
+ it('empty string is invalid', () => {
69
+ expect(isValidSlug('')).toBe(false);
70
+ });
71
+
72
+ it('single char "a" is invalid (too short)', () => {
73
+ expect(isValidSlug('a')).toBe(false);
74
+ });
75
+
76
+ it('two chars "ab" is invalid (too short)', () => {
77
+ expect(isValidSlug('ab')).toBe(false);
78
+ });
79
+
80
+ it('leading hyphen "-start" is invalid', () => {
81
+ expect(isValidSlug('-start')).toBe(false);
82
+ });
83
+
84
+ it('trailing hyphen "end-" is invalid', () => {
85
+ expect(isValidSlug('end-')).toBe(false);
86
+ });
87
+
88
+ it('spaces "with spaces" is invalid', () => {
89
+ expect(isValidSlug('with spaces')).toBe(false);
90
+ });
91
+
92
+ it('51 chars is invalid (too long)', () => {
93
+ const slug = 'a'.repeat(51);
94
+ expect(slug.length).toBe(51);
95
+ expect(isValidSlug(slug)).toBe(false);
96
+ });
97
+
98
+ it('uppercase letters "MyOrg" is invalid', () => {
99
+ expect(isValidSlug('MyOrg')).toBe(false);
100
+ });
101
+
102
+ it('underscores "my_org" are invalid', () => {
103
+ expect(isValidSlug('my_org')).toBe(false);
104
+ });
105
+
106
+ it('special chars "org@co" are invalid', () => {
107
+ expect(isValidSlug('org@co')).toBe(false);
108
+ });
109
+ });
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Email validation
114
+ // ---------------------------------------------------------------------------
115
+ describe('Email validation', () => {
116
+ describe('valid emails', () => {
117
+ it('"user@example.com" is valid', () => {
118
+ expect(isValidEmail('user@example.com')).toBe(true);
119
+ });
120
+
121
+ it('"a+b@x.co" is valid', () => {
122
+ expect(isValidEmail('a+b@x.co')).toBe(true);
123
+ });
124
+
125
+ it('"firstname.lastname@domain.org" is valid', () => {
126
+ expect(isValidEmail('firstname.lastname@domain.org')).toBe(true);
127
+ });
128
+
129
+ it('"user123@sub.domain.com" is valid', () => {
130
+ expect(isValidEmail('user123@sub.domain.com')).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe('invalid emails', () => {
135
+ it('"notanemail" is invalid (no @ or domain)', () => {
136
+ expect(isValidEmail('notanemail')).toBe(false);
137
+ });
138
+
139
+ it('"@domain.com" is invalid (no local part)', () => {
140
+ expect(isValidEmail('@domain.com')).toBe(false);
141
+ });
142
+
143
+ it('"user@" is invalid (no domain)', () => {
144
+ expect(isValidEmail('user@')).toBe(false);
145
+ });
146
+
147
+ it('empty string is invalid', () => {
148
+ expect(isValidEmail('')).toBe(false);
149
+ });
150
+
151
+ it('"user@domain" is invalid (no TLD)', () => {
152
+ expect(isValidEmail('user@domain')).toBe(false);
153
+ });
154
+
155
+ it('"user @example.com" is invalid (space in local part)', () => {
156
+ expect(isValidEmail('user @example.com')).toBe(false);
157
+ });
158
+ });
159
+ });
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // OrgRole enum validation
163
+ // ---------------------------------------------------------------------------
164
+ describe('OrgRole validation', () => {
165
+ describe('valid roles', () => {
166
+ it('"owner" is valid', () => {
167
+ expect(isValidOrgRole('owner')).toBe(true);
168
+ });
169
+
170
+ it('"admin" is valid', () => {
171
+ expect(isValidOrgRole('admin')).toBe(true);
172
+ });
173
+
174
+ it('"member" is valid', () => {
175
+ expect(isValidOrgRole('member')).toBe(true);
176
+ });
177
+
178
+ it('"viewer" is valid', () => {
179
+ expect(isValidOrgRole('viewer')).toBe(true);
180
+ });
181
+ });
182
+
183
+ describe('invalid roles', () => {
184
+ it('"superadmin" is invalid', () => {
185
+ expect(isValidOrgRole('superadmin')).toBe(false);
186
+ });
187
+
188
+ it('"Owner" is invalid (case-sensitive)', () => {
189
+ expect(isValidOrgRole('Owner')).toBe(false);
190
+ });
191
+
192
+ it('"ADMIN" is invalid (uppercase)', () => {
193
+ expect(isValidOrgRole('ADMIN')).toBe(false);
194
+ });
195
+
196
+ it('empty string is invalid', () => {
197
+ expect(isValidOrgRole('')).toBe(false);
198
+ });
199
+
200
+ it('"moderator" is invalid', () => {
201
+ expect(isValidOrgRole('moderator')).toBe(false);
202
+ });
203
+
204
+ it('"guest" is invalid', () => {
205
+ expect(isValidOrgRole('guest')).toBe(false);
206
+ });
207
+
208
+ it('"MEMBER" is invalid (uppercase)', () => {
209
+ expect(isValidOrgRole('MEMBER')).toBe(false);
210
+ });
211
+
212
+ it('"Viewer" is invalid (mixed case)', () => {
213
+ expect(isValidOrgRole('Viewer')).toBe(false);
214
+ });
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Token format validation (hex, exactly 64 chars)
220
+ // ---------------------------------------------------------------------------
221
+ describe('Token format validation', () => {
222
+ describe('valid tokens', () => {
223
+ it('"a".repeat(64) is valid', () => {
224
+ const token = 'a'.repeat(64);
225
+ expect(token.length).toBe(64);
226
+ expect(isValidToken(token)).toBe(true);
227
+ });
228
+
229
+ it('"abc123".repeat(10) + "ab" (64 hex chars) is valid', () => {
230
+ // "abc123" * 10 = 60 chars + "ab" = 62? Let's compute correctly:
231
+ // "abc123".repeat(10) = 60 chars, need 4 more: "abcd" => 64 total
232
+ const token = 'abc123'.repeat(10) + 'abcd';
233
+ expect(token.length).toBe(64);
234
+ // all chars in 'abc123abcd' are hex (a,b,c,d,1,2,3)
235
+ expect(isValidToken(token)).toBe(true);
236
+ });
237
+
238
+ it('all-zeros token "0".repeat(64) is valid', () => {
239
+ const token = '0'.repeat(64);
240
+ expect(isValidToken(token)).toBe(true);
241
+ });
242
+
243
+ it('all-f token "f".repeat(64) is valid', () => {
244
+ const token = 'f'.repeat(64);
245
+ expect(isValidToken(token)).toBe(true);
246
+ });
247
+
248
+ it('mixed hex chars covering 0-9 and a-f is valid', () => {
249
+ // 64 chars using full hex alphabet
250
+ const token = '0123456789abcdef'.repeat(4);
251
+ expect(token.length).toBe(64);
252
+ expect(isValidToken(token)).toBe(true);
253
+ });
254
+ });
255
+
256
+ describe('invalid tokens', () => {
257
+ it('"short" is invalid (too few chars)', () => {
258
+ expect(isValidToken('short')).toBe(false);
259
+ });
260
+
261
+ it('"z".repeat(64) is invalid ("z" is not a hex digit)', () => {
262
+ const token = 'z'.repeat(64);
263
+ expect(token.length).toBe(64);
264
+ expect(isValidToken(token)).toBe(false);
265
+ });
266
+
267
+ it('empty string is invalid', () => {
268
+ expect(isValidToken('')).toBe(false);
269
+ });
270
+
271
+ it('63 hex chars is invalid (one too short)', () => {
272
+ const token = 'a'.repeat(63);
273
+ expect(token.length).toBe(63);
274
+ expect(isValidToken(token)).toBe(false);
275
+ });
276
+
277
+ it('65 hex chars is invalid (one too long)', () => {
278
+ const token = 'a'.repeat(65);
279
+ expect(token.length).toBe(65);
280
+ expect(isValidToken(token)).toBe(false);
281
+ });
282
+
283
+ it('uppercase hex "A".repeat(64) is invalid (must be lowercase)', () => {
284
+ const token = 'A'.repeat(64);
285
+ expect(isValidToken(token)).toBe(false);
286
+ });
287
+
288
+ it('token with hyphens is invalid', () => {
289
+ // UUID-style, 32 hex + 4 hyphens = not valid token
290
+ const token = 'a'.repeat(28) + '-' + 'b'.repeat(27) + '-' + 'cc';
291
+ expect(isValidToken(token)).toBe(false);
292
+ });
293
+
294
+ it('token with spaces is invalid', () => {
295
+ const token = 'a'.repeat(32) + ' ' + 'b'.repeat(31);
296
+ expect(isValidToken(token)).toBe(false);
297
+ });
298
+
299
+ it('"g".repeat(64) is invalid ("g" is not hex)', () => {
300
+ const token = 'g'.repeat(64);
301
+ expect(isValidToken(token)).toBe(false);
302
+ });
303
+ });
304
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "target": "ES2020",
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "jsx": "react-jsx",
9
+ "noEmit": true
10
+ },
11
+ "include": ["src/client/**/*", "src/shared/**/*"],
12
+ "exclude": ["node_modules", "dist"]
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true,
7
+ "module": "NodeNext",
8
+ "moduleResolution": "NodeNext"
9
+ },
10
+ "include": ["src/server/**/*", "src/shared/**/*", "src/index.ts"],
11
+ "exclude": ["node_modules", "dist", "tests", "src/client"]
12
+ }