···104104 # parent, and its sub-resolvers only read `org.id`. A bit unusual, as
105105 # `MatchingBanks` could be a standalone type — may expand later.
106106 MatchingBanks: ../graphql/datasources/orgKyselyPersistence.js#GraphQLOrgParent
107107- User: ../models/UserModel.js#User
107107+ User: ../graphql/datasources/userKyselyPersistence.js#GraphQLUserParent
108108 LocationBank: ./datasources/LocationBankApi.js#LocationBankWithoutFullPlacesAPIResponse
109109 # TODO(Kysely migration): these parents will flip to
110110 # `../models/rules/ruleTypes.js#Rule` once the Action/Policy resolvers
+32-17
server/api.ts
···2929 makeLoginSsoRequiredError,
3030 makeLoginUserDoesNotExistError,
3131} from './graphql/datasources/UserApi.js';
3232+import {
3333+ kyselyUserFindByEmail,
3434+ kyselyUserFindById,
3535+} from './graphql/datasources/userKyselyPersistence.js';
3236import resolvers, { type Context } from './graphql/resolvers.js';
3737+import { passwordMatchesHash } from './services/userManagementService/index.js';
3338import typeDefs from './graphql/schema.js';
3439import { authSchemaWrapper } from './graphql/utils/authorization.js';
3540import { type Dependencies } from './iocContainer/index.js';
···91969297export default async function makeApiServer(deps: Dependencies) {
9398 const app = express();
9494- const { User } = deps.Sequelize;
9999+ const { KyselyPg } = deps;
9510096101 app.use(cors());
97102···213218 },
214219 async (_req, profile, done) => {
215220 try {
216216- const user = await User.findOne({
217217- where: { email: String(profile?.email) },
218218- });
221221+ const user = await kyselyUserFindByEmail(
222222+ KyselyPg,
223223+ String(profile?.email),
224224+ );
219225 // we should have already checked for this, but couldn't hurt to check
220226 // again
221227 if (user == null) {
···235241 },
236242 async (_req, profile, done) => {
237243 try {
238238- const user = await User.findOne({
239239- where: { email: String(profile?.email) },
240240- });
244244+ const user = await kyselyUserFindByEmail(
245245+ KyselyPg,
246246+ String(profile?.email),
247247+ );
241248 // we should have already checked for this, but couldn't hurt to check
242249 // again
243250 if (user == null) {
···278285 passport.use(
279286 new GraphQLLocalStrategy(async (email, password, done) => {
280287 try {
281281- const user = await User.findOne({ where: { email: String(email) } });
288288+ const user = await kyselyUserFindByEmail(KyselyPg, String(email));
282289 if (user == null) {
283290 return done(
284291 makeLoginUserDoesNotExistError({ shouldErrorSpan: true }),
···313320 );
314321 }
315322316316- // if loginMethod is password, password should be set
323323+ // `loginMethods` includes 'password', so the DB CHECK constraint
324324+ // guarantees `user.password` is non-null here.
317325 if (
318318- await User.passwordMatchesHash(
319319- String(password),
320320- user.password satisfies string | null as string,
321321- )
326326+ user.password != null &&
327327+ (await passwordMatchesHash(String(password), user.password))
322328 ) {
323329 done(null, user);
324330 } else {
···340346 });
341347342348 passport.deserializeUser(async (id, done) => {
343343- return User.findByPk(String(id), { rejectOnEmpty: true }).then((user) => {
344344- done(null, user);
345345- }, done);
349349+ try {
350350+ const user = await kyselyUserFindById(KyselyPg, String(id));
351351+ if (user == null) {
352352+ return done(
353353+ makeNotFoundError(`Session user ${String(id)} not found`, {
354354+ shouldErrorSpan: true,
355355+ }),
356356+ );
357357+ }
358358+ return done(null, user);
359359+ } catch (e) {
360360+ return done(e);
361361+ }
346362 });
347363348364 /**
···551567 'PartialItemsService',
552568 'ReportingService',
553569 'RuleEvaluator',
554554- 'Sequelize',
555570 'SignalsService',
556571 'SigningKeyPairService',
557572 'Tracer',
+13-8
server/graphql/datasources/RuleApi.ts
···8899import { inject, type Dependencies } from '../../iocContainer/index.js';
1010import { type Backtest } from '../../models/rules/BacktestModel.js';
1111-import { type User } from '../../models/UserModel.js';
1211import { type ActionCountsInput } from '../../services/actionStatisticsService/index.js';
1312import { type AggregationClause } from '../../services/aggregationsService/index.js';
1413import { type ConditionSetWithResultAsLogged } from '../../services/analyticsLoggers/index.js';
···6867 kyselyListBacktestsForRule,
6968 kyselyUpdateRule,
7069} from './ruleKyselyPersistence.js';
7070+import {
7171+ type GraphQLUserParent,
7272+ kyselyUserFindByIdAndOrg,
7373+} from './userKyselyPersistence.js';
7174import { locationAreaInputToLocationArea } from './LocationBankApi.js';
7275import { unauthenticatedError } from '../utils/errors.js';
7376import { isUniqueViolationError } from '../../utils/kysely.js';
···302305 public readonly ruleInsights: Dependencies['RuleActionInsights'],
303306 private readonly actionStats: Dependencies['ActionStatisticsService'],
304307 private readonly kyselyPg: Dependencies['KyselyPg'],
305305- private readonly models: Dependencies['Sequelize'],
306308 private readonly moderationConfigService: Dependencies['ModerationConfigService'],
307309 private readonly tracer: Dependencies['Tracer'],
308310 private readonly signalsService: Dependencies['SignalsService'],
···315317 );
316318 this.graphQlRuleParentDeps = {
317319 moderationConfigService: this.moderationConfigService,
318318- // TODO(Kysely migration): replace with a ModerationConfigService /
319319- // user-service-backed lookup once users are migrated off Sequelize.
320320 findUserByIdAndOrg: async (opts) =>
321321- this.models.User.findOne({ where: opts.where }),
321321+ kyselyUserFindByIdAndOrg(this.kyselyPg, opts),
322322 };
323323 }
324324···667667 * `ruleKyselyPersistence.ts`, and the deleted private helpers (`getContentItemTypeIdsForRule`,
668668 * `runSampledRuleExecutions`, `queryWarehouseSubmissionsForRule`).
669669 */
670670- async createBacktest(_input: GQLCreateBacktestInput, _user: User): Promise<Backtest> {
670670+ async createBacktest(
671671+ _input: GQLCreateBacktestInput,
672672+ _user: GraphQLUserParent,
673673+ ): Promise<Backtest> {
671674 throw new Error(
672675 'createBacktest is temporarily disabled (TODO BACKTEST_RETROACTION: no UI / env to validate).',
673676 );
···777780 * TODO(BACKTEST_RETROACTION): Same as `createBacktest` — re-enable when UI and env
778781 * support validation.
779782 */
780780- async runRetroaction(_input: GQLRunRetroactionInput, _user: User): Promise<{ _: boolean }> {
783783+ async runRetroaction(
784784+ _input: GQLRunRetroactionInput,
785785+ _user: GraphQLUserParent,
786786+ ): Promise<{ _: boolean }> {
781787 throw new Error(
782788 'runRetroaction is temporarily disabled (TODO BACKTEST_RETROACTION: no UI / env to validate).',
783789 );
···856862 'RuleActionInsights',
857863 'ActionStatisticsService',
858864 'KyselyPg',
859859- 'Sequelize',
860865 'ModerationConfigService',
861866 'Tracer',
862867 'SignalsService',
+183-68
server/graphql/datasources/UserApi.ts
···4455import { inject, type Dependencies } from '../../iocContainer/index.js';
66import { type Rule } from '../../models/rules/RuleModel.js';
77-import { type User as TUser } from '../../models/UserModel.js';
88-import { hashPassword } from '../../services/userManagementService/index.js';
77+import { type LoginMethod } from '../../services/coreAppTables.js';
88+import {
99+ hashPassword,
1010+ passwordMatchesHash,
1111+} from '../../services/userManagementService/index.js';
912import {
1013 CoopError,
1114 ErrorType,
1215 makeBadRequestError,
1316 makeInternalServerError,
1717+ makeNotFoundError,
1418 makeUnauthorizedError,
1519 type ErrorInstanceData,
1620} from '../../utils/errors.js';
1721import { safePick } from '../../utils/misc.js';
1822import { WEEK_MS } from '../../utils/time.js';
2323+import { buildGraphqlRuleParent } from './buildGraphqlRuleParent.js';
2424+import {
2525+ type GraphQLUserParent,
2626+ kyselyUserAddFavoriteRule,
2727+ kyselyUserFindByEmail,
2828+ kyselyUserFindByIdAndOrg,
2929+ kyselyUserFindById,
3030+ kyselyUserFindByIds,
3131+ kyselyUserInsert,
3232+ kyselyUserListFavoriteRuleIds,
3333+ kyselyUserRemoveFavoriteRule,
3434+ kyselyUserUpdate,
3535+} from './userKyselyPersistence.js';
3636+import {
3737+ type UserValidationFailure,
3838+ validateUserCreateInput,
3939+ validateUserUpdatePatch,
4040+} from './userValidation.js';
19412042/**
2143 * GraphQL Object for a User
2244 */
2345class UserAPI {
2446 constructor(
2525- private readonly sequelize: Dependencies['Sequelize'],
4747+ private readonly kyselyPg: Dependencies['KyselyPg'],
2648 private readonly tracer: Dependencies['Tracer'],
2749 private readonly userManagementService: Dependencies['UserManagementService'],
2828- ) {
2929- }
5050+ private readonly moderationConfigService: Dependencies['ModerationConfigService'],
5151+ ) {}
30523131- async getGraphQLUserFromId(opts: { id: string; orgId: string }) {
3232- const { id, orgId } = opts;
3333-3434- return this.sequelize.User.findOne({
3535- where: {
3636- id,
3737- orgId,
3838- },
3939- rejectOnEmpty: true,
4040- });
5353+ async getGraphQLUserFromId(opts: {
5454+ id: string;
5555+ orgId: string;
5656+ }): Promise<GraphQLUserParent> {
5757+ const user = await kyselyUserFindByIdAndOrg(this.kyselyPg, opts);
5858+ if (user === undefined) {
5959+ // Matches the `rejectOnEmpty: true` semantics of the Sequelize call
6060+ // this method replaced (callers rely on a throw when the row is
6161+ // missing, e.g. `getFavoriteRules`).
6262+ throw makeNotFoundError(
6363+ `User ${opts.id} not found in org ${opts.orgId}`,
6464+ { shouldErrorSpan: true },
6565+ );
6666+ }
6767+ return user;
4168 }
42694343- async getGraphQLUsersFromIds(ids: string[]) {
4444- return this.sequelize.User.findAll({
4545- where: { id: ids },
4646- });
7070+ async getGraphQLUsersFromIds(ids: string[]): Promise<GraphQLUserParent[]> {
7171+ return kyselyUserFindByIds(this.kyselyPg, ids);
4772 }
48734949- async login(params: any, context: PassportContext<TUser, any>) {
7474+ async login(params: any, context: PassportContext<GraphQLUserParent, any>) {
5075 const credentials = safePick(params.input, ['email', 'password']);
51765277 // NB: this will throw for bad credentials; will be handled in the resolver.
···7297 }
7398 }
74997575- async signUp(params: any, _: any) {
100100+ async signUp(params: any, _: any): Promise<GraphQLUserParent> {
76101 const { role } = params.input;
77102 const {
78103 email,
···90115 { shouldErrorSpan: true },
91116 );
921179393- const existingUser = await this.sequelize.User.findOne({
9494- where: { email },
9595- });
118118+ const existingUser = await kyselyUserFindByEmail(this.kyselyPg, email);
96119 if (existingUser != null) {
97120 throw makeSignUpUserExistsError({ shouldErrorSpan: true });
98121 }
···119142 });
120143 }
121144122122- const user = await this.sequelize.User.create({
145145+ const loginMethodNormalized = String(loginMethod).toLowerCase() as LoginMethod;
146146+ const createInput = {
147147+ email,
148148+ firstName,
149149+ lastName,
150150+ role: token.role,
151151+ loginMethods: [loginMethodNormalized] as const,
152152+ password: passwordToSave,
153153+ };
154154+155155+ const validation = validateUserCreateInput(createInput);
156156+ if (!validation.ok) {
157157+ throw userValidationFailureToBadRequestError(validation.failure);
158158+ }
159159+160160+ const user = await kyselyUserInsert({
161161+ db: this.kyselyPg,
123162 id: uid(),
163163+ orgId,
124164 email,
125165 password: passwordToSave,
126166 firstName,
127167 lastName,
128168 role: token.role,
129169 approvedByAdmin: true,
130130- orgId,
131131- loginMethods: [loginMethod.toLowerCase()],
170170+ loginMethods: [loginMethodNormalized],
132171 });
133172134173 // Delete the invite token after successful user creation
···138177 }
139178140179 async updateAccountInfo(
141141- user: TUser,
180180+ user: GraphQLUserParent,
142181 params: { firstName?: string | null; lastName?: string | null },
143143- ) {
144144- const { firstName, lastName } = params;
145145- if (firstName != null) {
146146- user.firstName = firstName;
182182+ ): Promise<GraphQLUserParent> {
183183+ const patch = {
184184+ firstName: params.firstName ?? undefined,
185185+ lastName: params.lastName ?? undefined,
186186+ };
187187+188188+ const validation = validateUserUpdatePatch(patch);
189189+ if (!validation.ok) {
190190+ throw userValidationFailureToBadRequestError(validation.failure);
147191 }
148148- if (lastName != null) {
149149- user.lastName = lastName;
192192+193193+ const updated = await kyselyUserUpdate(this.kyselyPg, user.id, patch);
194194+ if (updated == null) {
195195+ // Row went missing between load and update (e.g. concurrent delete).
196196+ throw makeNotFoundError(`User ${user.id} not found`, {
197197+ shouldErrorSpan: true,
198198+ });
150199 }
151151- await user.save();
200200+ return updated;
152201 }
153202154203 async changePassword(
155155- user: TUser,
204204+ user: GraphQLUserParent,
156205 params: { currentPassword: string; newPassword: string },
157206 ) {
158207 const { currentPassword, newPassword } = params;
159208160160- // Check if user has password login method
161209 if (!user.loginMethods.includes('password')) {
162210 throw makeChangePasswordNotAllowedError({
163211 detail: 'Password login is not enabled for this user.',
···165213 });
166214 }
167215168168- // Verify current password
169216 if (user.password == null) {
170217 throw makeChangePasswordIncorrectPasswordError({
171218 detail: 'Current password is not set.',
···173220 });
174221 }
175222176176- const isCurrentPasswordValid =
177177- await this.sequelize.User.passwordMatchesHash(
178178- currentPassword,
179179- user.password,
180180- );
223223+ const isCurrentPasswordValid = await passwordMatchesHash(
224224+ currentPassword,
225225+ user.password,
226226+ );
181227182228 if (!isCurrentPasswordValid) {
183229 throw makeChangePasswordIncorrectPasswordError({
···185231 });
186232 }
187233188188- // Hash and save new password
189234 const hashedNewPassword = await hashPassword(newPassword);
190190- user.password = hashedNewPassword;
191191- await user.save();
235235+ const updated = await kyselyUserUpdate(this.kyselyPg, user.id, {
236236+ password: hashedNewPassword,
237237+ });
238238+ if (updated == null) {
239239+ // Row went missing between load and update (e.g. concurrent delete).
240240+ throw makeNotFoundError(`User ${user.id} not found`, {
241241+ shouldErrorSpan: true,
242242+ });
243243+ }
192244193245 return {
194246 __typename: 'ChangePasswordSuccessResponse' as const,
···199251 async deleteUser(opts: { id: string; orgId: string }) {
200252 const { id, orgId } = opts;
201253 try {
202202- const user = await this.sequelize.User.findOne({ where: { id, orgId } });
203203- await user?.destroy();
254254+ const user = await kyselyUserFindByIdAndOrg(this.kyselyPg, {
255255+ id,
256256+ orgId,
257257+ });
258258+ if (user != null) {
259259+ await this.kyselyPg
260260+ .deleteFrom('public.users')
261261+ .where('id', '=', id)
262262+ .where('org_id', '=', orgId)
263263+ .execute();
264264+ }
204265 } catch (exception) {
205266 const activeSpan = this.tracer.getActiveSpan();
206267 if (activeSpan?.isRecording()) {
···212273 }
213274214275 async approveUser(id: string, invokerOrgId: string) {
215215- const user = await this.sequelize.User.findByPk(id, {
216216- rejectOnEmpty: true,
217217- });
276276+ const user = await kyselyUserFindById(this.kyselyPg, id);
277277+ if (user == null) {
278278+ throw makeNotFoundError(`User ${id} not found`, {
279279+ shouldErrorSpan: true,
280280+ });
281281+ }
218282219219- // Security check: ensure admin can only approve users in their own org
283283+ // Security check: ensure admin can only approve users in their own org.
220284 if (user.orgId !== invokerOrgId) {
221285 throw makeUnauthorizedError(
222286 'You can only approve users in your organization',
···224288 );
225289 }
226290227227- user.approvedByAdmin = true;
228228- await user.save();
291291+ const updated = await kyselyUserUpdate(this.kyselyPg, id, {
292292+ approvedByAdmin: true,
293293+ });
294294+ if (updated == null) {
295295+ // Row went missing between load and update (e.g. concurrent delete).
296296+ throw makeNotFoundError(`User ${id} not found`, {
297297+ shouldErrorSpan: true,
298298+ });
299299+ }
229300 return true;
230301 }
231302232303 async rejectUser(id: string, invokerOrgId: string) {
233233- const user = await this.sequelize.User.findByPk(id, {
234234- rejectOnEmpty: true,
235235- });
304304+ const user = await kyselyUserFindById(this.kyselyPg, id);
305305+ if (user == null) {
306306+ throw makeNotFoundError(`User ${id} not found`, {
307307+ shouldErrorSpan: true,
308308+ });
309309+ }
236310237237- // Security check: ensure admin can only reject users in their own org
311311+ // Security check: ensure admin can only reject users in their own org.
238312 if (user.orgId !== invokerOrgId) {
239313 throw makeUnauthorizedError(
240314 'You can only reject users in your organization',
···242316 );
243317 }
244318245245- user.rejectedByAdmin = true;
246246- await user.save();
319319+ const updated = await kyselyUserUpdate(this.kyselyPg, id, {
320320+ rejectedByAdmin: true,
321321+ });
322322+ if (updated == null) {
323323+ // Row went missing between load and update (e.g. concurrent delete).
324324+ throw makeNotFoundError(`User ${id} not found`, {
325325+ shouldErrorSpan: true,
326326+ });
327327+ }
247328 return true;
248329 }
249330250331 async getFavoriteRules(id: string, orgId: string): Promise<Array<Rule>> {
251251- const user = await this.getGraphQLUserFromId({ id, orgId });
252252- const rules = await user.getFavoriteRules();
253253- return rules;
332332+ // Make sure the requested user lives in the invoker's org (the caller
333333+ // always passes the invoker's orgId), then scope rule lookups to that
334334+ // org so cross-org data can't leak even if stale favorites exist.
335335+ await this.getGraphQLUserFromId({ id, orgId });
336336+ const ruleIds = await kyselyUserListFavoriteRuleIds(this.kyselyPg, id);
337337+ const plains = (
338338+ await Promise.all(
339339+ ruleIds.map(async (ruleId) =>
340340+ this.moderationConfigService.getRuleByIdAndOrg(ruleId, orgId),
341341+ ),
342342+ )
343343+ ).filter((plain): plain is NonNullable<typeof plain> => plain != null);
344344+ return plains.map((plain) =>
345345+ buildGraphqlRuleParent(plain, {
346346+ moderationConfigService: this.moderationConfigService,
347347+ findUserByIdAndOrg: async (opts) =>
348348+ kyselyUserFindByIdAndOrg(this.kyselyPg, opts),
349349+ }),
350350+ );
254351 }
255352256353 async addFavoriteRule(userId: string, ruleId: string, orgId: string) {
257257- const user = await this.getGraphQLUserFromId({ id: userId, orgId });
258258- await user.addFavoriteRules([ruleId]);
354354+ // Scope by org so a caller can't add a favorite targeting a rule in a
355355+ // different org (the Sequelize association was unscoped).
356356+ await this.getGraphQLUserFromId({ id: userId, orgId });
357357+ const rule = await this.moderationConfigService.getRuleByIdAndOrg(
358358+ ruleId,
359359+ orgId,
360360+ );
361361+ if (rule == null) {
362362+ throw makeNotFoundError(`Rule ${ruleId} not found in org ${orgId}`, {
363363+ shouldErrorSpan: true,
364364+ });
365365+ }
366366+ await kyselyUserAddFavoriteRule(this.kyselyPg, userId, ruleId);
259367 }
260368261369 async removeFavoriteRule(userId: string, ruleId: string, orgId: string) {
262262- const user = await this.getGraphQLUserFromId({ id: userId, orgId });
263263- await user.removeFavoriteRules([ruleId]);
370370+ await this.getGraphQLUserFromId({ id: userId, orgId });
371371+ await kyselyUserRemoveFavoriteRule(this.kyselyPg, userId, ruleId);
264372 }
265373}
266374375375+function userValidationFailureToBadRequestError(failure: UserValidationFailure) {
376376+ return makeBadRequestError(failure.message, {
377377+ pointer: `/input/${failure.field}`,
378378+ shouldErrorSpan: false,
379379+ });
380380+}
381381+267382export default inject(
268268- ['Sequelize', 'Tracer', 'UserManagementService'],
383383+ ['KyselyPg', 'Tracer', 'UserManagementService', 'ModerationConfigService'],
269384 UserAPI,
270385);
271386export type { UserAPI };
···11import { type Rule as SequelizeRule } from '../../models/rules/RuleModel.js';
22-import { type User } from '../../models/UserModel.js';
32import {
43 type PlainRuleWithLatestVersion,
54 type Rule as RuleGraphqlParent,
65} from '../../models/rules/ruleTypes.js';
76import { type ModerationConfigService } from '../../services/moderationConfigService/index.js';
77+import { type GraphQLUserParent } from './userKyselyPersistence.js';
8899type FindUserByIdAndOrg = (opts: {
1010- where: { id: string; orgId: string };
1111-}) => Promise<User | null>;
1010+ id: string;
1111+ orgId: string;
1212+}) => Promise<GraphQLUserParent | undefined>;
12131314/**
1415 * Builds a GraphQL Rule parent (plain row fields + the three association
1516 * getters our resolvers actually use) backed by ModerationConfigService
1616- * reads and a Sequelize-backed User lookup for the creator.
1717+ * reads and a Kysely-backed User lookup for the creator.
1718 *
1819 * The returned object only implements the {@link RuleGraphqlParent} contract
1920 * (`getCreator` / `getActions` / `getPolicies`). We cast to `SequelizeRule`
···3435 ...plain,
3536 async getCreator() {
3637 const user = await deps.findUserByIdAndOrg({
3737- where: { id: plain.creatorId, orgId: plain.orgId },
3838+ id: plain.creatorId,
3939+ orgId: plain.orgId,
3840 });
3941 if (user == null) {
4042 throw new Error(`User not found for rule creator ${plain.creatorId}`);
···4455import { type GQLServices } from '../api.js';
66import { type DataSources } from '../iocContainer/index.js';
77-import { type User } from '../models/UserModel.js';
77+import { type GraphQLUserParent } from './datasources/userKyselyPersistence.js';
88import { CoopError, isCoopErrorOfType } from '../utils/errors.js';
99import {
1010 type GQLInviteUserToken,
···4141import { forbiddenError, unauthenticatedError } from './utils/errors.js';
42424343// eslint-disable-next-line @typescript-eslint/no-restricted-types
4444-export type Context = PassportContext<User, {}> & {
4444+export type Context = PassportContext<GraphQLUserParent, {}> & {
4545 dataSources: DataSources;
4646 services: GQLServices;
4747};
···124124 },
125125};
126126127127-type TSignUpResponse = { data: User } | CoopError;
127127+type TSignUpResponse = { data: GraphQLUserParent } | CoopError;
128128const SignUpResponse: ResolverMap<TSignUpResponse> = {
129129 __resolveType(response) {
130130 if (response instanceof CoopError) {
+2-2
server/models/rules/ruleTypes.ts
···66 type Action,
77 type Policy,
88} from '../../services/moderationConfigService/index.js';
99-import { type User } from '../UserModel.js';
99+import { type GraphQLUserParent } from '../../graphql/datasources/userKyselyPersistence.js';
10101111export type RuleLatestVersionRow = {
1212 ruleId: string;
···5050}
51515252export type RuleGraphqlMethods = {
5353- getCreator(): Promise<User>;
5353+ getCreator(): Promise<GraphQLUserParent>;
5454 getActions(): Promise<Action[]>;
5555 getPolicies(): Promise<Policy[]>;
5656};
+24
server/services/coreAppTables.ts
···11import { type Generated, type GeneratedAlways } from 'kysely';
2233+import { type UserRole } from '../models/types/permissioning.js';
44+35/** Postgres enum for backtests.status (generated column — read-only in app). */
46export type BacktestStatusDb = 'RUNNING' | 'COMPLETE' | 'CANCELED';
77+88+/** Postgres enum for users.login_methods. */
99+export type LoginMethod = 'password' | 'saml';
510611export type CoreAppTablesPg = {
712 'public.orgs': {
···1318 created_at: Date;
1419 updated_at: Date;
1520 on_call_alert_email: string | null;
2121+ };
2222+ // `id`, `created_at`, `updated_at` are all NOT NULL with no server-side
2323+ // default — the app supplies them on INSERT, just like the Sequelize model
2424+ // did. The DB enforces a CHECK constraint (`password_null_when_not_present`)
2525+ // tying `password IS NOT NULL` to `'password' ∈ login_methods`; that
2626+ // invariant is enforced at the app layer too (see `userValidation.ts`).
2727+ 'public.users': {
2828+ id: string;
2929+ email: string;
3030+ password: string | null;
3131+ first_name: string;
3232+ last_name: string;
3333+ role: UserRole;
3434+ approved_by_admin: boolean;
3535+ rejected_by_admin: boolean;
3636+ created_at: Date;
3737+ updated_at: Date;
3838+ org_id: string;
3939+ login_methods: LoginMethod[];
1640 };
1741 'public.location_banks': {
1842 id: string;
+4-14
server/services/userManagementService/dbTypes.ts
···11import type { ColumnType, GeneratedAlways } from 'kysely';
2233import type { UserRole } from '../../models/types/permissioning.js';
44+import { type CoreAppTablesPg } from '../coreAppTables.js';
45import type {
56 DecisionCountsInput,
67 JobCreationsInput,
···5657 org_id: string;
5758 created_at: Date;
5859 };
5959- 'public.users': {
6060- id: GeneratedAlways<string>;
6161- email: string;
6262- password: string | null;
6363- first_name: string;
6464- last_name: string;
6565- role: UserRole;
6666- approved_by_admin: boolean;
6767- rejected_by_admin: boolean;
6868- created_at: GeneratedAlways<Date>;
6969- updated_at: GeneratedAlways<Date>;
7070- org_id: string;
7171- login_methods: ('password' | 'saml')[];
7272- };
6060+ // Shared definition lives in `services/coreAppTables.ts` so Kysely instances
6161+ // typed on either `UserManagementPg` or `CombinedPg` see the same columns.
6262+ 'public.users': CoreAppTablesPg['public.users'];
7363 'public.invite_user_tokens': {
7464 id: GeneratedAlways<string>;
7565 token: string;
+1-1
server/services/userManagementService/index.ts
···33 default as makeUserManagementService,
44 type UserManagementService,
55} from './userManagementService.js';
66-export { hashPassword } from './utils.js';
66+export { hashPassword, passwordMatchesHash } from './utils.js';
+13
server/services/userManagementService/utils.ts
···11+import { promisify } from 'util';
12import bcrypt from 'bcryptjs';
2334export async function hashPassword(rawPassword: string) {
45 return bcrypt.hash(rawPassword, 5);
56}
77+88+// Matches the bcrypt.compare semantics used by the now-removed Sequelize
99+// `User.passwordMatchesHash` static; kept here so `UserApi.changePassword` and
1010+// the Passport local strategy share a single implementation.
1111+const bcryptCompare = promisify(bcrypt.compare);
1212+1313+export async function passwordMatchesHash(
1414+ givenPassword: string,
1515+ hash: string,
1616+): Promise<boolean> {
1717+ return bcryptCompare(givenPassword, hash);
1818+}