import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { loadEnv } from "../../config/env.js"; import type { InternalAddMemberInput } from "./test-seam.dto.js"; /** * Test-only membership seam (TASK-0095 S9). * * Why this exists: the production two-user membership flow is email-gated * (D3 — the invite token is hashed and only delivered by mail, the HTTP * invite response carries no token, and there is no add-member-by-userId * route). An automated test therefore cannot get a second user into a * workspace without a mail server. This endpoint is that test seam: it adds * a REGISTERED user to a workspace directly, with the same membership row * the public accept route would create. * * Safety model (recorded in docs/tasks/phase-07-collaboration.md): * - Gated by the SAME fail-closed `InternalServiceGuard` as the rest of * `/internal/*` (shared `INTERNAL_SERVICE_TOKEN`, constant-time compare, * user JWTs rejected) — so the route is closed unless a service token is * configured. * - ADDITIONALLY closed unless `INTERNAL_TEST_SEAM=1` — a second, * independent env switch. Production sets neither; the only consumers are * the integration suite (in-process app) and the CI e2e job. * - It creates no invitation row, sends no mail, and grants no OWNER role. * The write is audited `workspace.test_member_added` so a stray call in * the wild is visible in the audit trail. */ @Injectable() export class TestSeamService { // Plain parameter injection (matches the sibling PageService / InternalBlocksService // that boot successfully; see internal.service.ts for the full regression note). constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} /** * Add a registered user to a workspace (upsert — re-adding an existing * member is a no-op that returns the current row). */ async addMember(workspaceId: string, input: InternalAddMemberInput) { if (loadEnv().INTERNAL_TEST_SEAM !== "1") { // Defense in depth: the route also checks the flag, but the service // re-checks so a mis-wired controller cannot open the seam. throw ApiError.unauthorized("Test seam is disabled.", [{ reason: "test_seam_disabled" }]); } const ws = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { id: true, deletedAt: true } }); if (!ws || ws.deletedAt) throw ApiError.notFound("Workspace not found."); const user = await this.prisma.user.findUnique({ where: { email: input.email.toLowerCase() }, select: { id: true, email: true } }); if (!user) throw ApiError.notFound("User not found — register the user first."); const member = await this.prisma.workspaceMember.upsert({ where: { workspaceId_userId: { workspaceId, userId: user.id } }, update: {}, create: { workspaceId, userId: user.id, role: input.role as never, joinedAt: new Date() } }); await this.audit.write({ action: "workspace.test_member_added", resourceType: "workspace_member", resourceId: member.id, workspaceId, metadata: { email: user.email, role: input.role, seam: true } }); return { id: member.id, workspaceId: member.workspaceId, userId: member.userId, role: member.role }; } }