UNPKG

@accounter/server

Version:
231 lines • 9.76 kB
import { __decorate, __metadata } from "tslib"; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import DataLoader from 'dataloader'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { AuditLogsProvider } from '../../common/providers/audit-logs.provider.js'; import { AuthContextProvider } from './auth-context.provider.js'; import { BusinessUsersProvider } from './business-users.provider.js'; const INVITATION_EXPIRATION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const getActiveInvitationsByEmails = sql ` SELECT id, email FROM accounter_schema.invitations WHERE business_id = $ownerId AND lower(email) = ANY(SELECT lower(u) FROM unnest($emails::varchar[]) AS u) AND accepted_at IS NULL AND expires_at > NOW();`; const getInvitationsByTokens = sql ` SELECT id, business_id, role_id, auth0_user_id, accepted_at, expires_at, token_hash FROM accounter_schema.invitations WHERE token_hash IN $$tokenHashes;`; // Strictly tenant-scoped: only pending (not yet accepted) invitations for the // current business are listed. The one-time token is never selected. const listInvitations = sql ` SELECT id, email, role_id, expires_at FROM accounter_schema.invitations WHERE business_id = $ownerId AND accepted_at IS NULL ORDER BY created_at DESC; `; // Strictly tenant-scoped: the business id guard prevents revoking an invitation // that belongs to another tenant. Only pending invitations can be revoked. const deleteInvitation = sql ` DELETE FROM accounter_schema.invitations WHERE id = $invitationId AND business_id = $ownerId AND accepted_at IS NULL RETURNING id, user_id; `; // Removes the pending placeholder membership created alongside an invitation. // Guarded by `auth0_user_id IS NULL` so an accepted member is never deleted. const deletePendingInvitationPlaceholder = sql ` DELETE FROM accounter_schema.business_users WHERE user_id = $userId AND business_id = $ownerId AND auth0_user_id IS NULL; `; const insertInvitation = sql ` INSERT INTO accounter_schema.invitations ( user_id, business_id, email, role_id, token_hash, auth0_user_created, auth0_user_id, invited_by_user_id, invited_by_business_id, expires_at ) VALUES ($userId, $ownerId, $email, $roleId, $tokenHash, $auth0UserCreated, $auth0UserId, $invitedByUserId, $invitedByBusinessId, $expiresAt) RETURNING id, email, business_id, role_id, expires_at; `; let InvitationsProvider = class InvitationsProvider { db; adminContextProvider; businessUsersProvider; auditLogsProvider; authContextProvider; constructor(db, adminContextProvider, businessUsersProvider, auditLogsProvider, authContextProvider) { this.db = db; this.adminContextProvider = adminContextProvider; this.businessUsersProvider = businessUsersProvider; this.auditLogsProvider = auditLogsProvider; this.authContextProvider = authContextProvider; } async batchInvitationsByEmails(emails) { const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext(); const invitations = await getActiveInvitationsByEmails.run({ emails: [...emails], ownerId, }, this.db); return emails.map(email => invitations.filter(t => t.email.toLowerCase() === email.toLowerCase())); } getInvitationByEmailLoader = new DataLoader((emails) => this.batchInvitationsByEmails(emails), { cacheKeyFn: email => email.toLowerCase(), }); async batchInvitationsByTokens(tokenHashes) { const invitations = await getInvitationsByTokens.run({ tokenHashes: [...tokenHashes], }, this.db); return tokenHashes.map(tokenHash => invitations.filter(t => t.token_hash === tokenHash)); } getInvitationByTokensLoader = new DataLoader((tokenHashes) => this.batchInvitationsByTokens(tokenHashes)); async insertInvitation({ email, roleId, auth0UserId, invitedByUserId, ownerId, }) { return this.db.transaction(async (client) => { const token = randomBytes(32).toString('hex'); const tokenHash = createHash('sha256').update(token).digest('hex'); const localUserId = randomUUID(); const invitationParams = { userId: localUserId, email, roleId, tokenHash, auth0UserCreated: true, auth0UserId, invitedByUserId, invitedByBusinessId: ownerId, ownerId, expiresAt: new Date(Date.now() + INVITATION_EXPIRATION_PERIOD_MS), }; await this.businessUsersProvider.insertBusinessUser({ userId: localUserId, auth0UserId: null, roleId, ownerId, }); const insertedInvitation = await insertInvitation.run(invitationParams, client); const [invitation] = insertedInvitation; if (!invitation) { throw new GraphQLError('Invitation creation failed: no record returned from DB.', { extensions: { code: 'INTERNAL_SERVER_ERROR' }, }); } await this.auditLogsProvider.insertAuditLog({ ownerId: invitation.business_id, userId: invitedByUserId, auth0UserId, action: 'INVITATION_CREATED', entity: 'Invitation', entityId: invitation.id, details: { email, roleId, auth0UserId }, }); return { id: invitation.id, email: invitation.email, roleId: invitation.role_id, expiresAt: invitation.expires_at, token, }; }); } /** * List the pending invitations for the current business. * Restricted to business owners and strictly scoped to the caller's tenant. * The one-time invitation token is never exposed. */ async listInvitations() { const authContext = await this.requireBusinessOwner(); const rows = await listInvitations.run({ ownerId: authContext.tenant.businessId }, this.db); return rows.map(row => ({ id: row.id, email: row.email, roleId: row.role_id, expiresAt: row.expires_at, })); } /** * Revoke (hard-delete) a pending invitation. * Restricted to business owners and strictly scoped to the caller's tenant, so * an invitation belonging to another business can never be revoked. Already * accepted invitations are not revocable. The pending placeholder membership * created alongside the invitation is removed too, so it does not linger as a * ghost user. The Auth0 user is intentionally left untouched: the same blocked * user may be shared by invitations across businesses (and is reused on * re-invite), so deleting it here could break unrelated tenants. * Returns false when no matching pending invitation exists in the current business. */ async revokeInvitation(invitationId) { const authContext = await this.requireBusinessOwner(); const ownerId = authContext.tenant.businessId; return this.db.transaction(async (client) => { const deleted = await deleteInvitation.run({ invitationId, ownerId }, client); const [invitation] = deleted; if (!invitation) { return false; } await deletePendingInvitationPlaceholder.run({ userId: invitation.user_id, ownerId }, client); await this.auditLogsProvider.log({ ownerId, userId: authContext.user.userId, auth0UserId: authContext.user.auth0UserId ?? undefined, action: 'INVITATION_REVOKED', entity: 'Invitation', entityId: invitation.id, details: { revoked: true }, }, client); return true; }); } async requireBusinessOwner() { const authContext = await this.authContextProvider.getAuthContext(); if (!authContext) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } const { user, tenant, ...restOfAuthContext } = authContext; if (!tenant?.businessId) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } if (!user) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } if (user.roleId !== 'business_owner') { throw new GraphQLError('Requires role: business_owner', { extensions: { code: 'FORBIDDEN' }, }); } return { ...restOfAuthContext, user, tenant }; } }; InvitationsProvider = __decorate([ Injectable({ scope: Scope.Operation, global: true, }), __metadata("design:paramtypes", [TenantAwareDBClient, AdminContextProvider, BusinessUsersProvider, AuditLogsProvider, AuthContextProvider]) ], InvitationsProvider); export { InvitationsProvider }; //# sourceMappingURL=invitations.provider.js.map