From 57e1d76d44b0e89e4bec7706c13254eb86e8aeae Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 18 May 2026 22:33:23 -0400 Subject: [PATCH 1/2] feat: organizations --- resources/coverage-badge.svg | 8 +- src/controllers/authentication.ts | 4 +- src/controllers/organizations.ts | 281 ++++++++++++++++++ src/controllers/user.ts | 43 +-- src/lib/token.ts | 8 +- src/middleware/verifyBearerAuth.ts | 3 + src/middleware/verifyCookieAuth.ts | 10 +- .../20260518120000-add-organizations.cjs | 122 ++++++++ src/models/organizationMemberships.ts | 105 +++++++ src/models/organizations.ts | 99 ++++++ src/models/sessions.ts | 6 + src/models/users.ts | 9 + src/routes/admin.routes.ts | 133 +++++++++ src/routes/organization.routes.ts | 151 ++++++++++ src/schemas/auth.responses.ts | 2 + src/schemas/me.response.ts | 26 ++ src/schemas/organization.requests.ts | 45 +++ src/services/organizationService.ts | 279 +++++++++++++++++ src/services/sessionIssuance.ts | 6 +- src/services/sessionService.ts | 5 +- src/types/types.ts | 1 + tests/factories/organizationFactory.ts | 39 +++ tests/factories/sessionFactory.ts | 4 + .../authentication/authentication.spec.ts | 1 + .../organizations/organizations.spec.ts | 151 ++++++++++ tests/setup/mocks.ts | 19 ++ tests/unit/controllers/authentication.spec.ts | 3 +- tests/unit/models/models.spec.ts | 2 + .../unit/services/sessionIssueService.spec.ts | 29 ++ tests/unit/services/sessionService.spec.ts | 2 + 30 files changed, 1569 insertions(+), 27 deletions(-) create mode 100644 src/controllers/organizations.ts create mode 100644 src/migrations/20260518120000-add-organizations.cjs create mode 100644 src/models/organizationMemberships.ts create mode 100644 src/models/organizations.ts create mode 100644 src/routes/organization.routes.ts create mode 100644 src/schemas/organization.requests.ts create mode 100644 src/services/organizationService.ts create mode 100644 tests/factories/organizationFactory.ts create mode 100644 tests/integration/organizations/organizations.spec.ts diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index ca9d3d6..f2075e9 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 82.3% + + coverage: 80.8% @@ -17,7 +17,7 @@ coverage coverage - 82.3% - 82.3% + 80.8% + 80.8% diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 68f689a..129ef14 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -343,6 +343,7 @@ export const refreshSession = async (req: Request, res: Response) => { userId: user.id, infraId: session.infraId, mode: session.mode, + organizationId: session.organizationId, refreshTokenHash: newRefreshTokenHash, refreshTokenLookup: newRefreshTokenLookup, userAgent: session.userAgent, @@ -354,7 +355,7 @@ export const refreshSession = async (req: Request, res: Response) => { session.replacedBySessionId = newSession.id; await session.save(); - const token = await signAccessToken(newSession.id, user.id, user.roles); + const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId); if (token && newRefreshToken) { logger.info( @@ -375,6 +376,7 @@ export const refreshSession = async (req: Request, res: Response) => { refreshToken: newRefreshToken, sub: user.id, sessionId: newSession.id, + organizationId: session.organizationId, roles: user.roles, email: user.email, phone: user.phone, diff --git a/src/controllers/organizations.ts b/src/controllers/organizations.ts new file mode 100644 index 0000000..594e61e --- /dev/null +++ b/src/controllers/organizations.ts @@ -0,0 +1,281 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { setAuthCookies } from '../lib/cookie.js'; +import { signAccessToken } from '../lib/token.js'; +import { OrganizationMembership } from '../models/organizationMemberships.js'; +import { Organization } from '../models/organizations.js'; +import { Session } from '../models/sessions.js'; +import { User } from '../models/users.js'; +import { + countOwners, + createOrganizationForUser, + findMembership, + listAllOrganizations, + listOrganizationMembers, + listOrganizationsForUser, + normalizeMembershipValues, + normalizeOrganizationRoles, + normalizeOrganizationSlug, + requireOrganizationAccess, + requireOrganizationManager, + serializeMembership, + serializeOrganization, +} from '../services/organizationService.js'; +import { AuthenticatedRequest } from '../types/types.js'; +import { parseDurationToSeconds } from '../utils/utils.js'; + +function authUser(req: Request) { + return (req as AuthenticatedRequest).user; +} + +function currentOrganizationId(req: Request) { + return (req as AuthenticatedRequest).organizationId ?? null; +} + +function currentSessionId(req: Request) { + return (req as AuthenticatedRequest).sessionId; +} + +export async function listOrganizations(req: Request, res: Response) { + const user = authUser(req); + const organizations = await listOrganizationsForUser(user.id); + + return res.json({ + organizations, + activeOrganizationId: currentOrganizationId(req), + }); +} + +export async function listAdminOrganizations(req: Request, res: Response) { + const organizations = await listAllOrganizations(); + return res.json({ organizations, total: organizations.length }); +} + +export async function createOrganization(req: Request, res: Response) { + const user = authUser(req); + const { name, slug, metadata } = req.body; + const { organization, membership } = await createOrganizationForUser({ + name, + slug, + user, + metadata, + }); + + return res.status(201).json({ + organization: serializeOrganization(organization, membership), + }); +} + +export async function getOrganization(req: Request, res: Response) { + const user = authUser(req); + const { organizationId } = req.params; + const { organization, membership } = await requireOrganizationAccess(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + return res.json({ + organization: serializeOrganization(organization, membership), + }); +} + +export async function updateOrganization(req: Request, res: Response) { + const user = authUser(req); + const { organizationId } = req.params; + const { organization, membership } = await requireOrganizationManager(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const updates: Partial> = {}; + + if (req.body.name !== undefined) { + updates.name = req.body.name; + } + + if (req.body.slug !== undefined) { + updates.slug = normalizeOrganizationSlug(req.body.slug); + } + + if (req.body.metadata !== undefined) { + updates.metadata = req.body.metadata; + } + + await organization.update(updates); + + return res.json({ + organization: serializeOrganization(organization, membership), + }); +} + +export async function switchOrganization(req: Request, res: Response) { + const user = authUser(req); + const { organizationId } = req.params; + const sessionId = currentSessionId(req); + const { organization, membership } = await requireOrganizationAccess(user, organizationId); + + if (!organization || !membership) { + return res.status(404).json({ error: 'Organization not found' }); + } + + if (!sessionId) { + return res.status(400).json({ error: 'Session context required' }); + } + + const session = await Session.findOne({ + where: { + id: sessionId, + userId: user.id, + }, + }); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + await session.update({ organizationId: organization.id }); + const token = await signAccessToken(session.id, user.id, user.roles, organization.id); + + if (process.env.AUTH_MODE === 'web') { + await setAuthCookies(res, { accessToken: token }); + return res.json({ + message: 'Success', + organization: serializeOrganization(organization, membership), + }); + } + + const { access_token_ttl } = await getSystemConfig(); + return res.json({ + message: 'Success', + token, + sub: user.id, + sessionId: session.id, + organizationId: organization.id, + organization: serializeOrganization(organization, membership), + ttl: parseDurationToSeconds(access_token_ttl || '15m'), + }); +} + +export async function listMembers(req: Request, res: Response) { + const user = authUser(req); + const { organizationId } = req.params; + const { organization } = await requireOrganizationAccess(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const members = await listOrganizationMembers(organizationId); + return res.json({ members, total: members.length }); +} + +export async function addMember(req: Request, res: Response) { + const user = authUser(req); + const { organizationId } = req.params; + const { organization } = await requireOrganizationManager(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const memberUser = req.body.userId + ? await User.findByPk(req.body.userId) + : await User.findOne({ where: { email: req.body.email.toLowerCase() } }); + + if (!memberUser) { + return res.status(404).json({ error: 'User not found' }); + } + + const existing = await findMembership(memberUser.id, organizationId); + + if (existing) { + return res.status(409).json({ error: 'User is already an organization member' }); + } + + const membership = await OrganizationMembership.create({ + organizationId, + userId: memberUser.id, + roles: normalizeOrganizationRoles(req.body.roles), + scopes: normalizeMembershipValues(req.body.scopes), + }); + + return res.status(201).json({ + membership: serializeMembership(membership, memberUser), + }); +} + +export async function updateMember(req: Request, res: Response) { + const user = authUser(req); + const { organizationId, userId } = req.params; + const { organization } = await requireOrganizationManager(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const membership = await findMembership(userId, organizationId); + + if (!membership) { + return res.status(404).json({ error: 'Membership not found' }); + } + + const nextRoles = + req.body.roles === undefined ? membership.roles : normalizeOrganizationRoles(req.body.roles); + + if (membership.roles?.includes('owner') && !nextRoles.includes('owner')) { + const ownerCount = await countOwners(organizationId); + if (ownerCount <= 1) { + return res.status(400).json({ error: 'Organization must keep at least one owner' }); + } + } + + await membership.update({ + roles: nextRoles, + scopes: + req.body.scopes === undefined + ? membership.scopes + : normalizeMembershipValues(req.body.scopes), + }); + + const memberUser = await User.findByPk(userId); + + return res.json({ + membership: serializeMembership(membership, memberUser ?? undefined), + }); +} + +export async function removeMember(req: Request, res: Response) { + const user = authUser(req); + const { organizationId, userId } = req.params; + const { organization } = await requireOrganizationManager(user, organizationId); + + if (!organization) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const membership = await findMembership(userId, organizationId); + + if (!membership) { + return res.status(404).json({ error: 'Membership not found' }); + } + + if (membership.roles?.includes('owner')) { + const ownerCount = await countOwners(organizationId); + if (ownerCount <= 1) { + return res.status(400).json({ error: 'Organization must keep at least one owner' }); + } + } + + await membership.destroy(); + + return res.json({ message: 'Success' }); +} diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 8ddc060..28ead99 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -11,6 +11,7 @@ import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; +import { listOrganizationsForUser } from '../services/organizationService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -23,23 +24,27 @@ export const getUser = async (req: Request, res: Response) => { try { if (authUser) { - const credentials = await Credential.findAll({ - where: { userId: authUser.id }, - attributes: [ - 'id', - 'transports', - 'deviceType', - 'backedup', - 'counter', - 'prfCapable', - 'friendlyName', - 'lastUsedAt', - 'platform', - 'browser', - 'deviceInfo', - 'createdAt', - ], - }); + const [credentials, organizations] = await Promise.all([ + Credential.findAll({ + where: { userId: authUser.id }, + attributes: [ + 'id', + 'transports', + 'deviceType', + 'backedup', + 'counter', + 'prfCapable', + 'friendlyName', + 'lastUsedAt', + 'platform', + 'browser', + 'deviceInfo', + 'createdAt', + ], + }), + listOrganizationsForUser(authUser.id), + ]); + const activeOrganizationId = authReq.organizationId ?? null; return res.json({ user: { @@ -48,8 +53,12 @@ export const getUser = async (req: Request, res: Response) => { phone: authUser.phone, roles: authUser.roles, lastLogin: authUser.lastLogin, + activeOrganizationId, }, credentials, + organizations, + activeOrganization: + organizations.find((organization) => organization.id === activeOrganizationId) ?? null, }); } else { return res.status(404).json({ message: 'User not found' }); diff --git a/src/lib/token.ts b/src/lib/token.ts index 3e89219..32fc82b 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -44,7 +44,12 @@ function getRefreshTokenLookupSecret() { ); } -export async function signAccessToken(sessionId: string, userId: string, roles?: string[]) { +export async function signAccessToken( + sessionId: string, + userId: string, + roles?: string[], + organizationId?: string | null, +) { const { kid, privateKeyPem } = await getSigningKey(); const privateKey = await importPKCS8(privateKeyPem, 'RS256'); @@ -56,6 +61,7 @@ export async function signAccessToken(sessionId: string, userId: string, roles?: iss: process.env.ISSUER, typ: 'access', roles, + ...(organizationId ? { org_id: organizationId } : {}), }) .setProtectedHeader({ alg: 'RS256', kid }) .setIssuedAt() diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index fae0c92..f855413 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -30,6 +30,9 @@ export async function verifyBearerAuth(req: Request, res: Response, next: NextFu if (result.sessionId !== undefined) { (req as AuthenticatedRequest).sessionId = result.sessionId; } + if (result.organizationId !== undefined) { + (req as AuthenticatedRequest).organizationId = result.organizationId; + } next(); } catch (err) { console.error('verifyBearerAuth failed:', err); diff --git a/src/middleware/verifyCookieAuth.ts b/src/middleware/verifyCookieAuth.ts index a92f37e..504181a 100644 --- a/src/middleware/verifyCookieAuth.ts +++ b/src/middleware/verifyCookieAuth.ts @@ -82,6 +82,8 @@ export function verifyCookieAuth(cookieType: CookieType = 'access') { if (user) { (req as AuthenticatedRequest).user = user; + (req as AuthenticatedRequest).sessionId = session.id; + (req as AuthenticatedRequest).organizationId = tokenData.organizationId; return next(); } } @@ -192,6 +194,7 @@ async function performSilentRefresh(req: Request, res: Response): Promise; + +export class OrganizationMembership + extends Model + implements OrganizationMembershipAttributes +{ + declare id: string; + declare organizationId: string; + declare userId: string; + declare roles: string[]; + declare scopes: string[]; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + + public static associations: { + organization: Association; + user: Association; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static associate(models: any) { + OrganizationMembership.belongsTo(models.Organization, { + foreignKey: 'organizationId', + as: 'organization', + onDelete: 'CASCADE', + }); + + OrganizationMembership.belongsTo(models.User, { + foreignKey: 'userId', + as: 'user', + onDelete: 'CASCADE', + }); + } +} + +const initializeOrganizationMembershipModel = (sequelize: Sequelize) => { + OrganizationMembership.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + }, + organizationId: { + type: DataTypes.UUID, + allowNull: false, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + roles: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: ['member'], + }, + scopes: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: [], + }, + }, + { + sequelize, + modelName: 'OrganizationMembership', + tableName: 'organization_memberships', + underscored: true, + indexes: [ + { + unique: true, + fields: ['organization_id', 'user_id'], + }, + ], + }, + ); + + return OrganizationMembership; +}; + +export default initializeOrganizationMembershipModel; diff --git a/src/models/organizations.ts b/src/models/organizations.ts new file mode 100644 index 0000000..252a88a --- /dev/null +++ b/src/models/organizations.ts @@ -0,0 +1,99 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Association, DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +import type { OrganizationMembership } from './organizationMemberships.js'; +import type { User } from './users.js'; + +export interface OrganizationAttributes { + id?: string; + name: string; + slug: string; + createdByUserId?: string | null; + metadata?: Record | null; + createdAt?: Date; + updatedAt?: Date; + memberships?: OrganizationMembership[]; +} + +type OrganizationCreationAttributes = Optional< + OrganizationAttributes, + 'id' | 'createdByUserId' | 'metadata' | 'createdAt' | 'updatedAt' | 'memberships' +>; + +export class Organization + extends Model + implements OrganizationAttributes +{ + declare id: string; + declare name: string; + declare slug: string; + declare createdByUserId: string | null; + declare metadata: Record | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + declare readonly memberships?: OrganizationMembership[]; + + public static associations: { + memberships: Association; + createdBy: Association; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static associate(models: any) { + Organization.hasMany(models.OrganizationMembership, { + foreignKey: 'organizationId', + onDelete: 'CASCADE', + as: 'memberships', + }); + + Organization.belongsTo(models.User, { + foreignKey: 'createdByUserId', + as: 'createdBy', + }); + } +} + +const initializeOrganizationModel = (sequelize: Sequelize) => { + Organization.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + slug: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + createdByUserId: { + type: DataTypes.UUID, + allowNull: true, + }, + metadata: { + type: DataTypes.JSON, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'Organization', + tableName: 'organizations', + underscored: true, + }, + ); + + return Organization; +}; + +export default initializeOrganizationModel; diff --git a/src/models/sessions.ts b/src/models/sessions.ts index 379444e..fc82848 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -10,6 +10,7 @@ export interface SessionAttributes { id: string; userId: string; infraId?: string | null; + organizationId?: string | null; mode: 'web' | 'server'; refreshTokenHash: string; refreshTokenLookup?: string | null; @@ -47,6 +48,7 @@ export class Session declare id: string; declare userId: string; declare infraId: string | null; + declare organizationId: string | null; declare mode: 'web' | 'server'; declare refreshTokenHash: string; declare refreshTokenLookup: string | null; @@ -81,6 +83,10 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true, }, + organizationId: { + type: DataTypes.UUID, + allowNull: true, + }, mode: { type: DataTypes.STRING, allowNull: false, diff --git a/src/models/users.ts b/src/models/users.ts index 6dc6508..800338a 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -7,6 +7,7 @@ import { Association, DataTypes, Model, Sequelize } from 'sequelize'; import type { Credential } from './credentials.js'; +import type { OrganizationMembership } from './organizationMemberships.js'; import type { TotpCredential } from './totpCredentials.js'; export interface UserAttributes { @@ -28,6 +29,7 @@ export interface UserAttributes { createdAt?: Date; updatedAt?: Date; credentials?: Credential[]; + organizationMemberships?: OrganizationMembership[]; totpCredentials?: TotpCredential[]; } @@ -50,10 +52,12 @@ export class User extends Model implements UserAttributes { declare readonly createdAt: Date; declare readonly updatedAt: Date; declare readonly credentials?: Credential[]; + declare readonly organizationMemberships?: OrganizationMembership[]; declare readonly totpCredentials?: TotpCredential[]; public static associations: { credentials: Association; + organizationMemberships: Association; totpCredentials: Association; }; @@ -69,6 +73,11 @@ export class User extends Model implements UserAttributes { onDelete: 'CASCADE', as: 'totpCredentials', }); + User.hasMany(models.OrganizationMembership, { + foreignKey: 'userId', + onDelete: 'CASCADE', + as: 'organizationMemberships', + }); } } diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 5d9579a..3b705a2 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -19,6 +19,16 @@ import { revokeAllUserSessions, updateUser, } from '../controllers/admin.js'; +import { + addMember, + createOrganization, + getOrganization, + listAdminOrganizations, + listMembers, + removeMember, + updateMember, + updateOrganization, +} from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { UserIdParamSchema } from '../schemas/admin.query.js'; @@ -30,10 +40,133 @@ import { CredentialCountSchema, UsersListResponseSchema, } from '../schemas/internal.responses.js'; +import { + AddOrganizationMemberRequestSchema, + CreateOrganizationRequestSchema, + OrganizationIdParamSchema, + OrganizationMemberParamSchema, + UpdateOrganizationMemberRequestSchema, + UpdateOrganizationRequestSchema, +} from '../schemas/organization.requests.js'; import { SessionListResponseSchema } from '../schemas/session.responses.js'; const adminRouter = createRouter('/admin'); +adminRouter.get( + '/organizations', + { + auth: 'access', + summary: 'List organizations', + tags: ['Admin'], + middleware: [requireAdmin()], + }, + listAdminOrganizations, +); + +adminRouter.post( + '/organizations', + { + auth: 'access', + summary: 'Create organization', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + body: CreateOrganizationRequestSchema, + }, + }, + createOrganization, +); + +adminRouter.get( + '/organizations/:organizationId', + { + auth: 'access', + summary: 'Get organization', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationIdParamSchema, + }, + }, + getOrganization, +); + +adminRouter.patch( + '/organizations/:organizationId', + { + auth: 'access', + summary: 'Update organization', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationIdParamSchema, + body: UpdateOrganizationRequestSchema, + }, + }, + updateOrganization, +); + +adminRouter.get( + '/organizations/:organizationId/members', + { + auth: 'access', + summary: 'List organization members', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationIdParamSchema, + }, + }, + listMembers, +); + +adminRouter.post( + '/organizations/:organizationId/members', + { + auth: 'access', + summary: 'Add organization member', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationIdParamSchema, + body: AddOrganizationMemberRequestSchema, + }, + }, + addMember, +); + +adminRouter.patch( + '/organizations/:organizationId/members/:userId', + { + auth: 'access', + summary: 'Update organization member', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationMemberParamSchema, + body: UpdateOrganizationMemberRequestSchema, + }, + }, + updateMember, +); + +adminRouter.delete( + '/organizations/:organizationId/members/:userId', + { + auth: 'access', + summary: 'Remove organization member', + tags: ['Admin'], + middleware: [requireAdmin()], + schemas: { + params: OrganizationMemberParamSchema, + response: { + 200: MessageSchema, + }, + }, + }, + removeMember, +); + adminRouter.get( '/users', { diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts new file mode 100644 index 0000000..e9e6e3b --- /dev/null +++ b/src/routes/organization.routes.ts @@ -0,0 +1,151 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + addMember, + createOrganization, + getOrganization, + listMembers, + listOrganizations, + removeMember, + switchOrganization, + updateMember, + updateOrganization, +} from '../controllers/organizations.js'; +import { createRouter } from '../lib/createRouter.js'; +import { MessageSchema } from '../schemas/generic.responses.js'; +import { + AddOrganizationMemberRequestSchema, + CreateOrganizationRequestSchema, + OrganizationIdParamSchema, + OrganizationMemberParamSchema, + UpdateOrganizationMemberRequestSchema, + UpdateOrganizationRequestSchema, +} from '../schemas/organization.requests.js'; + +const organizationRouter = createRouter('/organizations'); + +organizationRouter.get( + '/', + { + auth: 'access', + tags: ['Organizations'], + summary: 'List organizations for the authenticated user', + }, + listOrganizations, +); + +organizationRouter.post( + '/', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Create organization', + schemas: { + body: CreateOrganizationRequestSchema, + }, + }, + createOrganization, +); + +organizationRouter.get( + '/:organizationId', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Get organization', + schemas: { + params: OrganizationIdParamSchema, + }, + }, + getOrganization, +); + +organizationRouter.patch( + '/:organizationId', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Update organization', + schemas: { + params: OrganizationIdParamSchema, + body: UpdateOrganizationRequestSchema, + }, + }, + updateOrganization, +); + +organizationRouter.post( + '/:organizationId/switch', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Switch active organization for the current session', + schemas: { + params: OrganizationIdParamSchema, + }, + }, + switchOrganization, +); + +organizationRouter.get( + '/:organizationId/members', + { + auth: 'access', + tags: ['Organizations'], + summary: 'List organization members', + schemas: { + params: OrganizationIdParamSchema, + }, + }, + listMembers, +); + +organizationRouter.post( + '/:organizationId/members', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Add organization member', + schemas: { + params: OrganizationIdParamSchema, + body: AddOrganizationMemberRequestSchema, + }, + }, + addMember, +); + +organizationRouter.patch( + '/:organizationId/members/:userId', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Update organization member', + schemas: { + params: OrganizationMemberParamSchema, + body: UpdateOrganizationMemberRequestSchema, + }, + }, + updateMember, +); + +organizationRouter.delete( + '/:organizationId/members/:userId', + { + auth: 'access', + tags: ['Organizations'], + summary: 'Remove organization member', + schemas: { + params: OrganizationMemberParamSchema, + response: { + 200: MessageSchema, + }, + }, + }, + removeMember, +); + +export default organizationRouter.router; diff --git a/src/schemas/auth.responses.ts b/src/schemas/auth.responses.ts index e4b7413..974c457 100644 --- a/src/schemas/auth.responses.ts +++ b/src/schemas/auth.responses.ts @@ -22,6 +22,8 @@ export const RefreshSuccessResponseSchema = z.object({ token: z.string().optional(), refreshToken: z.string().optional(), sub: z.string().optional(), + sessionId: z.string().optional(), + organizationId: z.string().nullable().optional(), roles: z.array(z.string()).optional(), email: z.string().optional(), phone: z.string().nullable().optional(), diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts index 893db0d..cd6b27d 100644 --- a/src/schemas/me.response.ts +++ b/src/schemas/me.response.ts @@ -11,6 +11,28 @@ const CredentialWithPrfSchema = CredentialApiSchema.extend({ prfCapable: z.boolean().optional(), }); +const OrganizationMembershipSchema = z.object({ + id: z.string(), + organizationId: z.string(), + userId: z.string(), + roles: z.array(z.string()), + scopes: z.array(z.string()), + createdAt: z.any(), + updatedAt: z.any(), +}); + +const OrganizationSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + createdByUserId: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.any(), + updatedAt: z.any(), + membership: OrganizationMembershipSchema.optional(), + memberCount: z.number().optional(), +}); + export const MeResponseSchema = z.object({ user: UserSchema.pick({ id: true, @@ -18,6 +40,10 @@ export const MeResponseSchema = z.object({ phone: true, roles: true, lastLogin: true, + }).extend({ + activeOrganizationId: z.string().nullable().optional(), }), credentials: z.array(CredentialWithPrfSchema), + organizations: z.array(OrganizationSchema).optional(), + activeOrganization: OrganizationSchema.nullable().optional(), }); diff --git a/src/schemas/organization.requests.ts b/src/schemas/organization.requests.ts new file mode 100644 index 0000000..539cd7c --- /dev/null +++ b/src/schemas/organization.requests.ts @@ -0,0 +1,45 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +const MetadataSchema = z.record(z.string(), z.unknown()).nullable().optional(); + +export const OrganizationIdParamSchema = z.object({ + organizationId: z.string().uuid(), +}); + +export const OrganizationMemberParamSchema = OrganizationIdParamSchema.extend({ + userId: z.string().uuid(), +}); + +export const CreateOrganizationRequestSchema = z.object({ + name: z.string().trim().min(1).max(120), + slug: z.string().trim().min(1).max(100).optional(), + metadata: MetadataSchema, +}); + +export const UpdateOrganizationRequestSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + slug: z.string().trim().min(1).max(100).optional(), + metadata: MetadataSchema, +}); + +export const AddOrganizationMemberRequestSchema = z + .object({ + userId: z.string().uuid().optional(), + email: z.string().email().optional(), + roles: z.array(z.string().trim().min(1).max(80)).optional(), + scopes: z.array(z.string().trim().min(1).max(120)).optional(), + }) + .refine((value) => Boolean(value.userId || value.email), { + message: 'userId or email is required', + }); + +export const UpdateOrganizationMemberRequestSchema = z.object({ + roles: z.array(z.string().trim().min(1).max(80)).optional(), + scopes: z.array(z.string().trim().min(1).max(120)).optional(), +}); diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts new file mode 100644 index 0000000..64639be --- /dev/null +++ b/src/services/organizationService.ts @@ -0,0 +1,279 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Op } from 'sequelize'; + +import { OrganizationMembership } from '../models/organizationMemberships.js'; +import { Organization } from '../models/organizations.js'; +import { User } from '../models/users.js'; + +export type OrganizationRole = 'owner' | 'admin' | 'member'; + +export interface SerializedOrganizationMembership { + id: string; + organizationId: string; + userId: string; + roles: string[]; + scopes: string[]; + createdAt: Date; + updatedAt: Date; + user?: { + id: string; + email: string; + phone: string; + roles: string[]; + }; +} + +export interface SerializedOrganization { + id: string; + name: string; + slug: string; + createdByUserId: string | null; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; + membership?: SerializedOrganizationMembership; + memberCount?: number; +} + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); +} + +export function normalizeOrganizationSlug(name: string, slug?: string | null) { + const normalized = slugify(slug || name); + + if (normalized) { + return normalized; + } + + return `organization-${Date.now()}`; +} + +export function normalizeMembershipValues(input?: string[] | null) { + if (!Array.isArray(input)) return []; + + return Array.from( + new Set( + input + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .slice(0, 50), + ), + ); +} + +export function normalizeOrganizationRoles(input?: string[] | null) { + const roles = normalizeMembershipValues(input); + return roles.length > 0 ? roles : ['member']; +} + +async function buildUniqueSlug(slug: string) { + let candidate = slug; + let suffix = 1; + + while (await Organization.findOne({ where: { slug: candidate } })) { + suffix += 1; + candidate = `${slug}-${suffix}`; + } + + return candidate; +} + +export function serializeMembership( + membership: OrganizationMembership, + user?: User, +): SerializedOrganizationMembership { + return { + id: membership.id, + organizationId: membership.organizationId, + userId: membership.userId, + roles: Array.isArray(membership.roles) ? membership.roles : [], + scopes: Array.isArray(membership.scopes) ? membership.scopes : [], + createdAt: membership.createdAt, + updatedAt: membership.updatedAt, + ...(user + ? { + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + } + : {}), + }; +} + +export function serializeOrganization( + organization: Organization, + membership?: OrganizationMembership | null, + memberCount?: number, +): SerializedOrganization { + return { + id: organization.id, + name: organization.name, + slug: organization.slug, + createdByUserId: organization.createdByUserId, + metadata: organization.metadata ?? null, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + ...(membership ? { membership: serializeMembership(membership) } : {}), + ...(memberCount === undefined ? {} : { memberCount }), + }; +} + +export async function createOrganizationForUser({ + name, + slug, + user, + metadata, +}: { + name: string; + slug?: string | null; + user: User; + metadata?: Record | null; +}) { + const uniqueSlug = await buildUniqueSlug(normalizeOrganizationSlug(name, slug)); + const organization = await Organization.create({ + name, + slug: uniqueSlug, + createdByUserId: user.id, + metadata: metadata ?? null, + }); + + const membership = await OrganizationMembership.create({ + organizationId: organization.id, + userId: user.id, + roles: ['owner', 'admin'], + scopes: ['organization:read', 'organization:write', 'members:read', 'members:write'], + }); + + return { organization, membership }; +} + +export async function findMembership(userId: string, organizationId: string) { + return OrganizationMembership.findOne({ + where: { + organizationId, + userId, + }, + }); +} + +export function isOrganizationManager(user: User, membership?: OrganizationMembership | null) { + if (user.roles?.includes('admin')) return true; + return Boolean( + membership?.roles?.some((role) => role === 'owner' || role === 'admin'), + ); +} + +export async function requireOrganizationAccess(user: User, organizationId: string) { + const organization = await Organization.findByPk(organizationId); + + if (!organization) { + return { organization: null, membership: null }; + } + + const membership = await findMembership(user.id, organizationId); + + if (!membership && !user.roles?.includes('admin')) { + return { organization: null, membership: null }; + } + + return { organization, membership }; +} + +export async function requireOrganizationManager(user: User, organizationId: string) { + const { organization, membership } = await requireOrganizationAccess(user, organizationId); + + if (!organization || !isOrganizationManager(user, membership)) { + return { organization: null, membership: null }; + } + + return { organization, membership }; +} + +export async function listOrganizationsForUser(userId: string) { + const memberships = await OrganizationMembership.findAll({ + where: { userId }, + order: [['createdAt', 'ASC']], + }); + + const organizationIds = memberships.map((membership) => membership.organizationId); + + if (organizationIds.length === 0) { + return []; + } + + const organizations = await Organization.findAll({ + where: { id: { [Op.in]: organizationIds } }, + order: [['createdAt', 'ASC']], + }); + + const membershipsByOrg = new Map( + memberships.map((membership) => [membership.organizationId, membership]), + ); + + return organizations.map((organization) => + serializeOrganization(organization, membershipsByOrg.get(organization.id)), + ); +} + +export async function listAllOrganizations() { + const organizations = await Organization.findAll({ + order: [['createdAt', 'ASC']], + }); + + const counts = await Promise.all( + organizations.map((organization) => + OrganizationMembership.count({ where: { organizationId: organization.id } }), + ), + ); + + return organizations.map((organization, index) => + serializeOrganization(organization, null, counts[index]), + ); +} + +export async function getDefaultOrganizationIdForUser(userId: string) { + const membership = await OrganizationMembership.findOne({ + where: { userId }, + order: [['createdAt', 'ASC']], + }); + + return membership?.organizationId ?? null; +} + +export async function listOrganizationMembers(organizationId: string) { + const memberships = await OrganizationMembership.findAll({ + where: { organizationId }, + order: [['createdAt', 'ASC']], + }); + + const users = await User.findAll({ + where: { + id: { [Op.in]: memberships.map((membership) => membership.userId) }, + }, + }); + const usersById = new Map(users.map((user) => [user.id, user])); + + return memberships.map((membership) => serializeMembership(membership, usersById.get(membership.userId))); +} + +export async function countOwners(organizationId: string) { + const memberships = await OrganizationMembership.findAll({ + where: { organizationId }, + }); + + return memberships.filter((membership) => membership.roles?.includes('owner')).length; +} diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index b78e697..8894190 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -17,6 +17,7 @@ import { } from '../lib/token.js'; import { Session } from '../models/sessions.js'; import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; +import { getDefaultOrganizationIdForUser } from './organizationService.js'; type IssueSessionParams = { user: { @@ -39,10 +40,12 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis const refreshTokenHash = await hashRefreshToken(refreshToken); const refreshTokenLookup = createRefreshTokenLookup(refreshToken); const { expiresAt, idleExpiresAt } = computeSessionTimes(); + const organizationId = await getDefaultOrganizationIdForUser(user.id); const session = await Session.create({ userId: user.id, infraId: process.env.APP_ID!, + organizationId, mode: authMode, refreshTokenHash, refreshTokenLookup, @@ -53,7 +56,7 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis lastUsedAt: undefined, }); - const token = await signAccessToken(session.id, user.id, user.roles); + const token = await signAccessToken(session.id, user.id, user.roles, organizationId); if (!token || !refreshToken) { throw new Error('Failed to issue session tokens'); @@ -80,6 +83,7 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis token, refreshToken, sub: user.id, + organizationId, roles: user.roles, email: user.email, phone: user.phone, diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 1ed6e99..b2e7df7 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -119,6 +119,7 @@ export async function validateAccessToken(token: string) { userId, sessionId, roles: payload.roles || [], + organizationId: typeof payload.org_id === 'string' ? payload.org_id : null, }; } @@ -208,6 +209,7 @@ export async function getUserFromSession(session: Session) { export interface ValidatedBearerToken { user: User; sessionId?: string; + organizationId?: string | null; } export async function validateBearerToken(token: string) { @@ -233,9 +235,10 @@ export async function validateBearerToken(token: string) { } const sessionId = typeof payload.sid === 'string' ? payload.sid : undefined; + const organizationId = typeof payload.org_id === 'string' ? payload.org_id : null; const user = await User.findOne({ where: { id: payload.sub, revoked: false }, }); - return user ? { user, sessionId } : null; + return user ? { user, sessionId, organizationId } : null; } diff --git a/src/types/types.ts b/src/types/types.ts index c800787..3f28f64 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -12,6 +12,7 @@ import { User } from '../models/users.js'; export interface AuthenticatedRequest extends Request { user: User; sessionId: Session['id']; + organizationId?: string | null; clientId?: string; trustedClientIp?: string; } diff --git a/tests/factories/organizationFactory.ts b/tests/factories/organizationFactory.ts new file mode 100644 index 0000000..2c0fc47 --- /dev/null +++ b/tests/factories/organizationFactory.ts @@ -0,0 +1,39 @@ +import { vi } from 'vitest'; + +export const testOrganizationId = '2c0d53c2-a541-452b-b71b-54c7f15e5877'; + +export function buildOrganization(overrides: Partial = {}) { + return { + id: testOrganizationId, + name: 'Acme, Inc.', + slug: 'acme', + createdByUserId: 'user-1', + metadata: null, + createdAt: new Date('2026-05-18T12:00:00.000Z'), + updatedAt: new Date('2026-05-18T12:00:00.000Z'), + update: vi.fn().mockImplementation(function update(this: any, values: any) { + Object.assign(this, values); + return Promise.resolve(this); + }), + destroy: vi.fn(), + ...overrides, + }; +} + +export function buildOrganizationMembership(overrides: Partial = {}) { + return { + id: '47f96fd8-0140-4d18-ad10-f3346dd1df5e', + organizationId: testOrganizationId, + userId: 'user-1', + roles: ['owner', 'admin'], + scopes: ['organization:read', 'organization:write'], + createdAt: new Date('2026-05-18T12:00:00.000Z'), + updatedAt: new Date('2026-05-18T12:00:00.000Z'), + update: vi.fn().mockImplementation(function update(this: any, values: any) { + Object.assign(this, values); + return Promise.resolve(this); + }), + destroy: vi.fn(), + ...overrides, + }; +} diff --git a/tests/factories/sessionFactory.ts b/tests/factories/sessionFactory.ts index 4b2ef41..e06641d 100644 --- a/tests/factories/sessionFactory.ts +++ b/tests/factories/sessionFactory.ts @@ -10,6 +10,10 @@ export function buildSession(overrides: any = {}) { lastUsedAt: new Date(), expiresAt: new Date(Date.now() + 100000), revokedAt: null, + update: vi.fn().mockImplementation(function update(this: any, values: any) { + Object.assign(this, values); + return Promise.resolve(this); + }), save: vi.fn(), ...overrides, }; diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index d6cabe9..5d8aaf0 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -220,6 +220,7 @@ describe('POST /refresh', () => { 'new-session', expect.any(String), expect.any(Array), + undefined, ); expect(Session.create).toHaveBeenCalledWith( expect.objectContaining({ refreshTokenLookup: 'refresh-lookup' }), diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts new file mode 100644 index 0000000..cf4d4c9 --- /dev/null +++ b/tests/integration/organizations/organizations.spec.ts @@ -0,0 +1,151 @@ +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Application } from 'express'; + +import { createApp } from '../../../src/app'; +import { Organization } from '../../../src/models/organizations.js'; +import { OrganizationMembership } from '../../../src/models/organizationMemberships.js'; +import { Session } from '../../../src/models/sessions.js'; +import { User } from '../../../src/models/users.js'; +import { + buildOrganization, + buildOrganizationMembership, + testOrganizationId, +} from '../../factories/organizationFactory'; +import { buildSession } from '../../factories/sessionFactory'; +import { buildUser } from '../../factories/userFactory'; +import { signAccessToken } from '../../../src/lib/token.js'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('organizations', () => { + it('lists organizations for the authenticated user', async () => { + (OrganizationMembership.findAll as any).mockResolvedValue([ + buildOrganizationMembership(), + ]); + (Organization.findAll as any).mockResolvedValue([buildOrganization()]); + + const res = await request(app).get('/organizations'); + + expect(res.status).toBe(200); + expect(res.body.organizations).toHaveLength(1); + expect(res.body.organizations[0].membership.roles).toEqual([ + 'owner', + 'admin', + ]); + }); + + it('creates an organization with an owner membership', async () => { + (Organization.findOne as any).mockResolvedValue(null); + (Organization.create as any).mockResolvedValue(buildOrganization()); + (OrganizationMembership.create as any).mockResolvedValue( + buildOrganizationMembership(), + ); + + const res = await request(app) + .post('/organizations') + .send({ name: 'Acme, Inc.' }); + + expect(res.status).toBe(201); + expect(Organization.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Acme, Inc.', + slug: 'acme-inc', + createdByUserId: 'user-1', + }), + ); + expect(OrganizationMembership.create).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: testOrganizationId, + userId: 'user-1', + roles: ['owner', 'admin'], + }), + ); + }); + + it('adds an organization member by email', async () => { + (Organization.findByPk as any).mockResolvedValue(buildOrganization()); + (OrganizationMembership.findOne as any) + .mockResolvedValueOnce(buildOrganizationMembership()) + .mockResolvedValueOnce(null); + (User.findOne as any).mockResolvedValue( + buildUser({ + id: 'a1863941-552c-428a-aecd-599814979e8d', + email: 'member@example.com', + }), + ); + (OrganizationMembership.create as any).mockResolvedValue( + buildOrganizationMembership({ + userId: 'a1863941-552c-428a-aecd-599814979e8d', + roles: ['member'], + scopes: ['billing:read'], + }), + ); + + const res = await request(app) + .post(`/organizations/${testOrganizationId}/members`) + .send({ + email: 'member@example.com', + scopes: ['billing:read'], + }); + + expect(res.status).toBe(201); + expect(OrganizationMembership.create).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: testOrganizationId, + userId: 'a1863941-552c-428a-aecd-599814979e8d', + roles: ['member'], + scopes: ['billing:read'], + }), + ); + }); + + it('switches the current session organization', async () => { + const session = buildSession({ id: 'session-1', userId: 'user-1' }); + + (Organization.findByPk as any).mockResolvedValue(buildOrganization()); + (OrganizationMembership.findOne as any).mockResolvedValue( + buildOrganizationMembership(), + ); + (Session.findOne as any).mockResolvedValue(session); + (signAccessToken as any).mockResolvedValue('organization-access-token'); + (getSystemConfig as any).mockResolvedValue({ access_token_ttl: '15m' }); + + const res = await request(app).post( + `/organizations/${testOrganizationId}/switch`, + ); + + expect(res.status).toBe(200); + expect(session.update).toHaveBeenCalledWith({ + organizationId: testOrganizationId, + }); + expect(signAccessToken).toHaveBeenCalledWith( + 'session-1', + 'user-1', + ['user'], + testOrganizationId, + ); + }); +}); + +describe('admin organizations', () => { + it('lists all organizations for admins', async () => { + (Organization.findAll as any).mockResolvedValue([buildOrganization()]); + (OrganizationMembership.count as any).mockResolvedValue(2); + + const res = await request(app).get('/admin/organizations'); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.organizations[0].memberCount).toBe(2); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index f5dee9b..23414dd 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -46,6 +46,25 @@ vi.mock('../../src/models/totpCredentials.js', () => ({ }, })); +vi.mock('../../src/models/organizations.js', () => ({ + Organization: { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + findByPk: vi.fn(), + count: vi.fn(), + }, +})); + +vi.mock('../../src/models/organizationMemberships.js', () => ({ + OrganizationMembership: { + create: vi.fn(), + findAll: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + count: vi.fn(), + }, +})); + vi.mock('../../src/models/sessions.js', () => ({ Session: { create: vi.fn(), diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index f2c45d2..46fc8f9 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -165,7 +165,7 @@ describe('refreshSession', () => { refreshTokenLookup: 'new-refresh-lookup', }), ); - expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles); + expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined); expect(setAuthCookies).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ @@ -174,6 +174,7 @@ describe('refreshSession', () => { refreshToken: 'new-raw-refresh-token', sub: user.id, roles: user.roles, + organizationId: undefined, sessionId: 'session-2', email: user.email, phone: user.phone, diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index a31bda0..3cf6500 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -7,6 +7,8 @@ vi.unmock('../../../src/models/systemConfig.js'); vi.unmock('../../../src/models/credentials.js'); vi.unmock('../../../src/models/totpCredentials.js'); vi.unmock('../../../src/models/magicLinks.js'); +vi.unmock('../../../src/models/organizations.js'); +vi.unmock('../../../src/models/organizationMemberships.js'); describe('models initialization', () => { beforeEach(() => { diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 28a28af..27241f3 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -28,6 +28,10 @@ vi.mock('../../../src/config/getSystemConfig.js', () => ({ getSystemConfig: vi.fn(), })); +vi.mock('../../../src/services/organizationService.js', () => ({ + getDefaultOrganizationIdForUser: vi.fn(), +})); + vi.mock('../../../src/utils/utils.js', () => ({ computeSessionTimes: vi.fn(), parseDurationToSeconds: vi.fn(), @@ -46,6 +50,7 @@ import { Session } from '../../../src/models/sessions.js'; import { setAuthCookies, clearAuthCookies } from '../../../src/lib/cookie.js'; import { clearBootstrapCookie } from '../../../src/lib/bootstrapCookie.js'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { getDefaultOrganizationIdForUser } from '../../../src/services/organizationService.js'; import { computeSessionTimes, parseDurationToSeconds } from '../../../src/utils/utils.js'; // ---- Helpers ---- @@ -93,6 +98,7 @@ beforeEach(() => { }); (parseDurationToSeconds as any).mockImplementation((v: string) => (v === '15m' ? 900 : 3600)); + (getDefaultOrganizationIdForUser as any).mockResolvedValue(null); }); it('issues session in web mode and sets cookies', async () => { @@ -137,6 +143,7 @@ it('issues session in server mode and returns JSON payload', async () => { token: 'access-token', refreshToken: 'refresh-token', sub: mockUser.id, + organizationId: null, roles: mockUser.roles, email: mockUser.email, phone: mockUser.phone, @@ -206,8 +213,30 @@ it('passes request metadata into session creation', async () => { expect.objectContaining({ userAgent: 'test-agent', ipAddress: '127.0.0.1', + organizationId: null, + }), + ); +}); + +it('stores the default organization when one exists', async () => { + const req = mockReq(); + const res = mockRes(); + + (getDefaultOrganizationIdForUser as any).mockResolvedValue('org-1'); + + await issueSessionAndRespond({ + user: mockUser, + req, + res, + authMode: 'server', + }); + + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-1', }), ); + expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, 'org-1'); }); it('uses default TTL values when config missing', async () => { diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index 6c2e940..a69fd1a 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -118,6 +118,7 @@ describe('sessionService', () => { userId: 'user', sessionId: 'session', roles: ['admin'], + organizationId: null, }); }); @@ -338,6 +339,7 @@ describe('sessionService', () => { expect(result).toEqual({ user, sessionId: 'session-1', + organizationId: null, }); }); From d5e27f8a50167ea16e2ce323c29c6ebd8a4f04aa Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 18 May 2026 22:33:41 -0400 Subject: [PATCH 2/2] ci: linting --- src/services/organizationService.ts | 8 +++--- .../organizations/organizations.spec.ts | 25 +++++-------------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts index 64639be..5c973e6 100644 --- a/src/services/organizationService.ts +++ b/src/services/organizationService.ts @@ -172,9 +172,7 @@ export async function findMembership(userId: string, organizationId: string) { export function isOrganizationManager(user: User, membership?: OrganizationMembership | null) { if (user.roles?.includes('admin')) return true; - return Boolean( - membership?.roles?.some((role) => role === 'owner' || role === 'admin'), - ); + return Boolean(membership?.roles?.some((role) => role === 'owner' || role === 'admin')); } export async function requireOrganizationAccess(user: User, organizationId: string) { @@ -267,7 +265,9 @@ export async function listOrganizationMembers(organizationId: string) { }); const usersById = new Map(users.map((user) => [user.id, user])); - return memberships.map((membership) => serializeMembership(membership, usersById.get(membership.userId))); + return memberships.map((membership) => + serializeMembership(membership, usersById.get(membership.userId)), + ); } export async function countOwners(organizationId: string) { diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts index cf4d4c9..e8a3bde 100644 --- a/tests/integration/organizations/organizations.spec.ts +++ b/tests/integration/organizations/organizations.spec.ts @@ -29,31 +29,22 @@ beforeEach(() => { describe('organizations', () => { it('lists organizations for the authenticated user', async () => { - (OrganizationMembership.findAll as any).mockResolvedValue([ - buildOrganizationMembership(), - ]); + (OrganizationMembership.findAll as any).mockResolvedValue([buildOrganizationMembership()]); (Organization.findAll as any).mockResolvedValue([buildOrganization()]); const res = await request(app).get('/organizations'); expect(res.status).toBe(200); expect(res.body.organizations).toHaveLength(1); - expect(res.body.organizations[0].membership.roles).toEqual([ - 'owner', - 'admin', - ]); + expect(res.body.organizations[0].membership.roles).toEqual(['owner', 'admin']); }); it('creates an organization with an owner membership', async () => { (Organization.findOne as any).mockResolvedValue(null); (Organization.create as any).mockResolvedValue(buildOrganization()); - (OrganizationMembership.create as any).mockResolvedValue( - buildOrganizationMembership(), - ); + (OrganizationMembership.create as any).mockResolvedValue(buildOrganizationMembership()); - const res = await request(app) - .post('/organizations') - .send({ name: 'Acme, Inc.' }); + const res = await request(app).post('/organizations').send({ name: 'Acme, Inc.' }); expect(res.status).toBe(201); expect(Organization.create).toHaveBeenCalledWith( @@ -113,16 +104,12 @@ describe('organizations', () => { const session = buildSession({ id: 'session-1', userId: 'user-1' }); (Organization.findByPk as any).mockResolvedValue(buildOrganization()); - (OrganizationMembership.findOne as any).mockResolvedValue( - buildOrganizationMembership(), - ); + (OrganizationMembership.findOne as any).mockResolvedValue(buildOrganizationMembership()); (Session.findOne as any).mockResolvedValue(session); (signAccessToken as any).mockResolvedValue('organization-access-token'); (getSystemConfig as any).mockResolvedValue({ access_token_ttl: '15m' }); - const res = await request(app).post( - `/organizations/${testOrganizationId}/switch`, - ); + const res = await request(app).post(`/organizations/${testOrganizationId}/switch`); expect(res.status).toBe(200); expect(session.update).toHaveBeenCalledWith({