import { Injectable } from '@nestjs/common'; import { NotFoundError } from '@task-stream/api/shared/domain'; import { ProjectRole } from '@task-stream/api/tracker/domain'; import { FindProjectsRepository } from '../ports/find-projects.repository'; /** * Authorisation for the read side, based on the projected memberships: * members read their projects; anyone (also anonymous, `viewerId` null) * reads public ones. */ @Injectable() export class ReadAccess { constructor(private readonly findProjects: FindProjectsRepository) {} /** * The viewer's role, or null when they read a public project as an outsider. * `asked` is what the caller looked for: the error names it, never the * project behind it (that would confirm a private project exists). */ async assertCanView( projectId: string, viewerId: string | null, asked: { entity: string; ref: string } = { entity: 'Project', ref: projectId, }, ): Promise { const role = viewerId ? await this.findProjects.findRole(projectId, viewerId) : null; if (role) return role; const project = await this.findProjects.findById(projectId); // Same answer for "doesn't exist" and "not yours": don't leak projects. if (!project?.isPublic) throw new NotFoundError(asked.entity, asked.ref); return null; } async canView(projectId: string, viewerId: string | null): Promise { try { await this.assertCanView(projectId, viewerId); return true; } catch (error) { if (error instanceof NotFoundError) return false; throw error; } } assertFound(value: T | null, entity: string, id: string): T { if (!value) throw new NotFoundError(entity, id); return value; } } export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;