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:
UnifiedAuthGuard(global viaAPP_GUARD): Deny-by-default. Non-@Public()routes require JWT, system token, or API key. ThrowsUnauthorizedException('Authentication required')for unauthenticated requests.- Dev bypass: In non-production, localhost requests get
createDevCaller()withuserId: 'localhost-dev',platformRole: 'developer'. This is NOT a security hole — it's dev ergonomics. MongoDB lookups against this synthetic ID return nothing, so operations fail gracefully. RolesGuard(global viaAPP_GUARD): Only enforces when@Roles()or@RequirePermission()decorators are present. Currently NO such decorators onPlatformUsersController— any authenticated user can call these endpoints.- Ownership check:
PlatformUsersService.findById()verifiesuser.ownerId.toString() !== ownerIdand throwsForbiddenException. This is the ONLY IDOR protection and it's in the service layer, not the guard layer. @Public()routes:login,register,forgot-password,reset-passwordonPlatformUserAuthControllerare intentionally public.- There is NO
ServiceOnlyGuardin the codebase. Internal service auth usesBACKEND_API_INTERNAL_TOKENas a Bearer token validated manually. The notification specs referenceServiceOnlyGuard— that guard must be created or the existing token validation pattern must be used instead.
Task 1: Fix the Lying forgot-password Endpoint
Priority: Highest. This is actively deceptive.
Current behavior
requestPasswordReset()atplatform-users.service.ts:277-285:console.log()+ returns silently- Controller at
platform-users.controller.ts:296-301: returns{ success: true, message: 'If the email exists, a password reset link has been sent.' } - Result: Customer thinks reset email was sent. Nothing was sent. Silent data loss of trust.
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
APIs/backend-api/src/app/platform-users/platform-users.service.ts— replace both methodslibs/libs-api/backend-core/src/lib/services/platform-user.service.ts— addfindByResetToken(universeId, token)methodAPIs/backend-api/src/app/platform-users/platform-users.module.ts— ensureEmailServiceis injectable (likely already is viaMessagingCoreModule)
Dependencies
- Task 3 (slug resolution) — extract
resolveUniverseId()as a shared private method first, then both Task 1 and Task 4 use it.
Verification
POST /api/platform/test-universe/auth/forgot-passwordwith valid email → email actually sent (check SendGrid dashboard or dev console log)POST /api/platform/test-universe/auth/forgot-passwordwith invalid email → same 200 response, no email sent, no errorPOST /api/platform/test-universe/auth/reset-passwordwith valid token → password changed, token clearedPOST /api/platform/test-universe/auth/reset-passwordwith expired token → 400
Task 2: Wire Bulk Invite Email Delivery
Priority: High. Invited customers never receive credentials.
Current behavior
platform-user.service.ts:714-728(core service): generates temp password, creates user, has// TODO: Send invitation email with temp password- Customer is created in DB with a password they'll never know.
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
libs/libs-api/backend-core/src/lib/services/platform-user.service.ts— injectEmailService, add send after create- Ensure
MessagingCoreModuleis imported in the module that providesPlatformUserService
Verification
POST /api/spaces/:sid/universes/:uid/users/invitewith{ emails: ["[email protected]"] }→ email received with temp password- Created user can log in with the temp password
Task 3: Slug Resolution
Priority: High. Login only works when client sends raw universeId.
Current behavior
platform-users.service.ts:199-202:const universeId = universeSlug; // TODO: implement slug resolution- Works if client sends the MongoDB
_id. Fails with human-readable slugs likemy-cool-app.
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
APIs/backend-api/src/app/platform-users/platform-users.service.ts— add method, inject universe modelAPIs/backend-api/src/app/platform-users/platform-users.module.ts— addMongooseModule.forFeaturefor the universe schema if not already registered- Update
login(),selfRegister(),requestPasswordReset(),resetPassword(),getProfile()— all useuniverseSlugand need resolution
Important
- The
universescollection stores documents with bothid(string) andslug(string) fields — confirmed viadeployment.service.ts:628-633where it queries{ id: universeId }and reads.slug. - The
PlatformUserdocuments storeuniverseIdwhich matchesuniverse.id, NOTuniverse._id. - If no universe schema exists in
@libs-api/backend-core, use raw MongoDB:this.connection.db.collection('universes').findOne({ slug: universeSlug }).
Verification
POST /api/platform/my-cool-app/auth/loginwith valid creds → returns JWT (currently fails)POST /api/platform/507f1f77bcf86cd799439011/auth/login→ still works (ObjectId passthrough)
Task 4: Self-Registration Behind a Flag
Priority: Medium. Required for deployed platforms to be usable.
Current behavior
platform-users.service.ts:240-246: throwsNotImplementedExceptionUser.platformUserSettings.allowSelfRegistrationexists on schema but is never checked
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
- This is a
@Public()endpoint — NO rate limiting exists on it today - Add
@Throttle()decorator (from@nestjs/throttler) or manual rate limiting - Email verification: the
emailVerified,emailVerificationToken,emailVerificationExpiryfields exist onPlatformUserbut are not wired. For MVP, setemailVerified: falseon create. Full verification flow is Phase 2. - Validate password strength via DTO validation (already has
@MinLength(8))
Files to modify
APIs/backend-api/src/app/platform-users/platform-users.service.ts— replace stub- May need to inject
userModel(GoGrabUser) and MongoDB connection for universe lookup
Verification
- With
allowSelfRegistration: falseon owner → 403 - With
allowSelfRegistration: true→ user created + JWT returned - Duplicate email → 409
- Capacity exceeded → 403
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
libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts— add fieldsAPIs/backend-api/src/app/platform-users/platform-users.dto.ts— add toUpdatePlatformUserDtoandCreatePlatformUserDto
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
UnifiedAuthGuardensures authentication (JWT required in production)RolesGuardis globally registered but does nothing because no@Roles()on the controller- Protection relies solely on
ownerIdchecks inPlatformUsersService.findById() - Risk: IDOR if any ownership check has a bug
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
APIs/backend-api/src/app/platform-users/platform-users.controller.ts— add@Roles()to both controllers
Verification
- Authenticated user with
roles: ['viewer']callingGET /api/spaces/.../users→ 403 - Authenticated user with
roles: ['manager']→ 200 (existing behavior)
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
libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts— fix hierarchy commentAPIs/backend-api/src/app/platform-users/platform-users.controller.ts— fix L2 → L4
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:
Spec 1 — Platform Notification System (
2026-03-19-platform-notification-system-design.md): ExtendProactiveDispatcherServicewith broadcast, log, and bridge. Create the internal notification endpoint (note:ServiceOnlyGuardneeds to be created or replaced with the existingBACKEND_API_INTERNAL_TOKENpattern).Spec 2 Phase 1 — Business Communications (
2026-03-19-business-communications-system-design.md):CommunicationsModulein backend-api with templates, email delivery, consent. Install@nestjs/event-emitterin backend-api for ecommerce integration.Customer Management UI — Angular feature module for space builders (parallel track).
Rules for the Executing Agent
- Do NOT refactor surrounding code. Fix only what this spec describes.
- Reuse existing patterns.
magic-link.service.tsis the reference for token + email.EmailService.sendEmail()is the send method.@Roles()is the decorator. Don't invent new patterns. - Test the lying endpoint first.
forgot-passwordreturning success with no email is the highest-impact bug. Fix and verify before moving on. - Slug resolution is the foundation. Extract it as a shared method first — Tasks 1 and 4 both need it.
- 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. - Commit atomically per task. Each task is independently shippable.