2026 03 19 Platform User Foundation Fix

Documentation for Plans

PlatformUser Foundation Fix — Make the Surface Truthful

Session: S241 Date: 2026-03-19 Status: Ready for execution Priority: P0 — Blocks both notification specs Estimated scope: 7 tasks, ~4-5 hours Prerequisites: None — all patterns and dependencies already exist in the codebase


Why This Exists

The PlatformUser surface (L4 customer management) has endpoints that lie, stubs that throw, and security gaps. Two notification/communication specs (2026-03-19-platform-notification-system-design.md and 2026-03-19-business-communications-system-design.md) are designed and approved — but they depend on this surface being truthful and secure. This spec fixes the foundation so those specs can ship.

Non-goals: This is NOT a CRM, NOT a frontend UI, NOT VittoIdentity encryption. Those are separate tracks.


Canonical References

What File
PlatformUser schema libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts
Core service (MongoDB ops) libs/libs-api/backend-core/src/lib/services/platform-user.service.ts
API service (business logic) APIs/backend-api/src/app/platform-users/platform-users.service.ts
Controller (routes) APIs/backend-api/src/app/platform-users/platform-users.controller.ts
DTOs APIs/backend-api/src/app/platform-users/platform-users.dto.ts
EmailService (SendGrid) libs/libs-api/messaging-core/src/lib/services/email.service.ts
Magic link (reference pattern) APIs/backend-api/src/app/auth/magic-link.service.ts
@Roles decorator libs/libs-api/auth-core/src/lib/decorators/roles.decorator.ts
SystemRoles constants libs/libs-api/auth-core/src/lib/auth-core.constants.ts
UnifiedAuthGuard libs/libs-api/auth-core/src/lib/guards/unified-auth.guard.ts
Universe collection universes collection — documents have id and slug fields

Auth Context (Verified)

Before implementing, understand the existing security model:


Task 1: Fix the Lying forgot-password Endpoint

Priority: Highest. This is actively deceptive.

Current behavior

What to implement

Follow the magic-link.service.ts pattern (lines 68-117):

// In platform-users.service.ts — replace requestPasswordReset()

async requestPasswordReset(universeSlug: string, email: string): Promise<void> {
  // 1. Resolve slug → universeId (Task 3)
  const universeId = await this.resolveUniverseId(universeSlug);

  // 2. Find user (silently return if not found — don't reveal existence)
  const user = await this.platformUserService.findByEmail(universeId, email);
  if (!user) return;

  // 3. Generate token (same pattern as magic-link.service.ts:261-263)
  const token = randomBytes(32).toString('base64url');
  const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

  // 4. Store on user document (fields already exist on PlatformUser schema)
  await this.platformUserService.update(user._id.toString(), {
    passwordResetToken: token,
    passwordResetExpiry: expiresAt,
  });

  // 5. Send email (EmailService already imported by backend-api)
  const baseUrl = process.env.PLATFORM_FRONTEND_URL || process.env.FRONTEND_URL || 'https://gograb.space';
  const resetLink = `${baseUrl}/platform/${universeSlug}/reset-password?token=${token}`;

  await this.emailService.sendEmail({
    to: email,
    subject: 'Reset your password',
    html: `...password reset template HTML...`,
    text: `Reset your password: ${resetLink}\n\nThis link expires in 1 hour.`,
  });
}

What to implement for resetPassword()

Replace the NotImplementedException at platform-users.service.ts:291-298:

async resetPassword(universeSlug: string, token: string, newPassword: string): Promise<void> {
  const universeId = await this.resolveUniverseId(universeSlug);

  // Find user with valid, unexpired token
  const user = await this.platformUserService.findByResetToken(universeId, token);
  if (!user) {
    throw new BadRequestException('Invalid or expired reset token');
  }

  if (user.passwordResetExpiry < new Date()) {
    throw new BadRequestException('Reset token has expired');
  }

  // Update password (PlatformUserService.update() should hash via bcrypt)
  // Clear token fields
  await this.platformUserService.update(user._id.toString(), {
    password: newPassword, // Service must bcrypt hash this
    passwordResetToken: null,
    passwordResetExpiry: null,
  });
}

Files to modify

Dependencies

Verification


Task 2: Wire Bulk Invite Email Delivery

Priority: High. Invited customers never receive credentials.

Current behavior

What to implement

After this.create(...) at line 725, before created.push(email):

// After line 725 in platform-user.service.ts (core service)

// Send invitation email with credentials
if (this.emailService) {
  const platformName = 'Your Platform'; // TODO: resolve from universe metadata
  await this.emailService.sendEmail({
    to: email,
    subject: `You've been invited to ${platformName}`,
    html: `
      <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
        <h1 style="color: #1a1a2e; font-size: 24px; text-align: center;">Welcome!</h1>
        <p style="color: #374151; font-size: 16px; line-height: 1.6;">
          You've been invited to join <strong>${platformName}</strong>. Here are your credentials:
        </p>
        <div style="background: #f3f4f6; border-radius: 8px; padding: 20px; margin: 24px 0;">
          <p style="margin: 4px 0;"><strong>Email:</strong> ${email}</p>
          <p style="margin: 4px 0;"><strong>Temporary password:</strong> ${tempPassword}</p>
        </div>
        <p style="color: #6b7280; font-size: 14px;">
          Please change your password after your first login.
        </p>
      </div>
    `,
    text: `You've been invited to ${platformName}.\n\nEmail: ${email}\nTemporary password: ${tempPassword}\n\nPlease change your password after your first login.`,
  });
}

Files to modify

Verification


Task 3: Slug Resolution

Priority: High. Login only works when client sends raw universeId.

Current behavior

What to implement

Add a shared private method to PlatformUsersService:

// In platform-users.service.ts

/**
 * Resolve a universe slug to its internal ID.
 * Universes are stored in per-space databases, but also in the main
 * 'universes' collection with { id, slug } fields.
 */
private async resolveUniverseId(universeSlug: string): Promise<string> {
  // If it looks like a MongoDB ObjectId, use it directly
  if (/^[a-f\d]{24}$/i.test(universeSlug)) {
    return universeSlug;
  }

  // Query universes collection by slug
  const universe = await this.universeModel.findOne({ slug: universeSlug }).lean();
  if (!universe) {
    throw new NotFoundException(`Universe not found: ${universeSlug}`);
  }

  return universe.id || universe._id.toString();
}

Files to modify

Important

Verification


Task 4: Self-Registration Behind a Flag

Priority: Medium. Required for deployed platforms to be usable.

Current behavior

What to implement

async selfRegister(universeSlug: string, dto: CreatePlatformUserDto) {
  // 1. Resolve slug
  const universeId = await this.resolveUniverseId(universeSlug);

  // 2. Find the owning user (space builder) for this universe
  // Need to look up which user owns this universe
  const existingUser = await this.platformUserService.findByEmail(universeId, 'any');
  // Better: query universe → get spaceId → get space → get owner
  const universe = await this.connection.db.collection('universes').findOne({
    $or: [{ slug: universeSlug }, { id: universeId }],
  });
  if (!universe) throw new NotFoundException('Platform not found');

  const owner = await this.userModel.findById(universe.userId || universe.ownerId);
  if (!owner) throw new NotFoundException('Platform owner not found');

  // 3. Check if self-registration is enabled
  if (!owner.platformUserSettings?.allowSelfRegistration) {
    throw new ForbiddenException('Self-registration is not enabled for this platform');
  }

  // 4. Check capacity
  const canCreate = await this.platformUserService.canCreateMoreUsers(owner._id.toString());
  if (!canCreate) {
    throw new ForbiddenException('This platform has reached its user capacity');
  }

  // 5. Check duplicate
  const existing = await this.platformUserService.findByEmail(universeId, dto.email);
  if (existing) {
    throw new ConflictException('An account with this email already exists');
  }

  // 6. Create with default roles from owner settings
  const defaultRoles = owner.platformUserSettings?.defaultRoles || ['member'];

  const user = await this.platformUserService.create({
    ownerId: owner._id.toString(),
    spaceId: universe.spaceId,
    universeId,
    email: dto.email,
    password: dto.password,
    name: dto.name,
    roles: defaultRoles,
    permissions: [],
  });

  // 7. Generate JWT so user is immediately logged in
  const payload: PlatformUserJwtPayload = {
    sub: user._id?.toString() || '',
    email: user.email,
    userType: 'platform_user',
    mcpTier: 'free',
    spaceId: universe.spaceId,
    universeId,
    ownerId: owner._id.toString(),
    roles: defaultRoles,
  };

  const token = this.jwtService.sign(payload);

  return { user, token };
}

Security considerations

Files to modify

Verification


Task 5: Add Phone Field to PlatformUser

Priority: Low (quick). Unblocks Spec 2 Phase 2 later.

What to implement

// Add to libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts

/** S241: Contact phone for WhatsApp/SMS communications */
@Prop()
phone?: string;

/** S241: Phone country code (e.g., '+1', '+34') */
@Prop()
phoneCountryCode?: string;

Also add to UpdatePlatformUserDto:

@IsOptional()
@IsString()
phone?: string;

@IsOptional()
@IsString()
phoneCountryCode?: string;

Files to modify

No migration needed

These are optional fields. Existing documents remain valid.


Task 6: Add @Roles Guard to PlatformUsersController

Priority: Medium. Defense-in-depth — service-layer ownership check exists but guard-layer role restriction does not.

Current state

What to implement

// platform-users.controller.ts

import { Roles } from '@libs-api/auth-core';
import { SystemRoles } from '@libs-api/auth-core';

@Roles(SystemRoles.MANAGER, SystemRoles.ADMIN, SystemRoles.DEVELOPER, SystemRoles.EXECUTIVE)
@Controller('spaces/:spaceId/universes/:universeId/users')
export class PlatformUsersController {
  // ... existing code unchanged
}

This ensures only manager+ roles (which maps to builder+ in PlatformRole) can access customer CRUD. user and viewer roles are blocked at the guard layer.

UserHierarchyController should get the same treatment:

@Roles(SystemRoles.MANAGER, SystemRoles.ADMIN, SystemRoles.DEVELOPER, SystemRoles.EXECUTIVE)
@Controller('users/hierarchy')
export class UserHierarchyController {
  // ...
}

Files to modify

Verification


Task 7: Fix Comment Rot (L2 → L4)

Priority: Low. Prevents agents from making wrong assumptions.

What to change

File Line Current Correct
platform-user.schema.ts 32 Represents end-users of generated platforms (L2 users). Represents end-users of generated platforms (L4 users).
platform-user.schema.ts 37-39 Space Builder (User - L1) ... Platform User (PlatformUser - L2) Space Builder (User - L2) ... Platform User (PlatformUser - L4)
platform-users.controller.ts 28 API endpoints for managing platform users (L2 users). API endpoints for managing platform users (L4 users).
platform-users.controller.ts 29 These are end-users of generated platforms created by space builders. Keep as-is (correct)

Files to modify


Execution Order

Tasks have dependencies:

Task 3 (slug resolution) ← FIRST — Tasks 1 and 4 depend on it
  ↓
Task 1 (password reset) ← Uses resolveUniverseId()
  ↓
Task 2 (invite email) ← Independent, can parallel with Task 1
  ↓
Task 4 (self-registration) ← Uses resolveUniverseId(), needs universe/owner lookup
  ↓
Task 5 (phone field) ← Independent, trivial
Task 6 (@Roles guard) ← Independent, trivial
Task 7 (comment fix) ← Independent, trivial

Recommended commit sequence:

[S242.X] fix: add universe slug resolution for platform user auth
[S242.X] fix: implement password reset flow for platform users
[S242.X] fix: send invitation email with credentials in bulk invite
[S242.X] feat: implement platform user self-registration behind flag
[S242.X] feat: add phone and phoneCountryCode fields to PlatformUser
[S242.X] security: add @Roles guard to PlatformUsersController
[S242.X] docs: fix L2→L4 tier labels in platform user comments

What Comes After

Once this spec is complete:

  1. Spec 1 — Platform Notification System (2026-03-19-platform-notification-system-design.md): Extend ProactiveDispatcherService with broadcast, log, and bridge. Create the internal notification endpoint (note: ServiceOnlyGuard needs to be created or replaced with the existing BACKEND_API_INTERNAL_TOKEN pattern).

  2. Spec 2 Phase 1 — Business Communications (2026-03-19-business-communications-system-design.md): CommunicationsModule in backend-api with templates, email delivery, consent. Install @nestjs/event-emitter in backend-api for ecommerce integration.

  3. Customer Management UI — Angular feature module for space builders (parallel track).


Rules for the Executing Agent

  1. Do NOT refactor surrounding code. Fix only what this spec describes.
  2. Reuse existing patterns. magic-link.service.ts is the reference for token + email. EmailService.sendEmail() is the send method. @Roles() is the decorator. Don't invent new patterns.
  3. Test the lying endpoint first. forgot-password returning success with no email is the highest-impact bug. Fix and verify before moving on.
  4. Slug resolution is the foundation. Extract it as a shared method first — Tasks 1 and 4 both need it.
  5. Don't add unnecessary complexity. Self-registration MVP does not need captcha, email verification, or rate limiting beyond what @Throttle() provides. Those are Phase 2.
  6. Commit atomically per task. Each task is independently shippable.