Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion cloud/packages/cloud/src/models/user.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// cloud/src/models/user.model.ts
import mongoose, { Schema, Document, Model, Types } from "mongoose";
import { randomUUID } from "crypto";
import { type AppSetting } from "@mentra/sdk";
import { MongoSanitizer } from "../utils/mongoSanitizer";
import { logger } from "../services/logging/pino-logger";
Expand All @@ -19,6 +20,12 @@ interface InstalledApp {

// Extend Document for TypeScript support
export interface UserI extends Document {
/**
* Canonical, opaque user identifier (UUID)
* Used as the primary identifier instead of email.
*/
userId: string;

email: string;
runningApps: string[];
appSettings: Map<string, AppSetting[]>;
Expand Down Expand Up @@ -131,6 +138,13 @@ const AppSettingUpdateSchema = new Schema(
// --- User Schema ---
const UserSchema = new Schema<UserI>(
{
userId: {
type: String,
required: true,
unique: true,
index: true,
default: () => randomUUID(),
},
email: {
type: String,
required: true,
Expand Down Expand Up @@ -266,7 +280,8 @@ const UserSchema = new Schema<UserI>(
toJSON: {
transform: (doc, ret) => {
delete ret.__v;
ret.id = ret._id;
// Prefer userId as the public identifier, fall back to Mongo _id
ret.id = ret.userId || ret._id;
delete ret._id;
// Safely handle appSettings transformation
if (ret.appSettings && ret.appSettings instanceof Map) {
Expand Down Expand Up @@ -567,6 +582,10 @@ UserSchema.statics.findByEmail = async function (email: string): Promise<UserI |
return this.findOne({ email: email.toLowerCase() });
};

UserSchema.statics.findByUserId = async function (userId: string): Promise<UserI | null> {
return this.findOne({ userId });
};

UserSchema.statics.findOrCreateUser = async function (email: string): Promise<UserI> {
email = email.toLowerCase();
let user = await this.findOne({ email });
Expand Down Expand Up @@ -844,6 +863,7 @@ UserSchema.statics.ensurePersonalOrg = async function (user: UserI): Promise<Typ
// --- Interface for Static Methods ---
interface UserModel extends Model<UserI> {
findByEmail(email: string): Promise<UserI | null>;
findByUserId(userId: string): Promise<UserI | null>;
findOrCreateUser(email: string): Promise<UserI>;
findUserInstalledApps(email: string): Promise<any[]>;
ensurePersonalOrg(user: UserI): Promise<Types.ObjectId>;
Expand Down
89 changes: 89 additions & 0 deletions cloud/packages/cloud/src/scripts/migrate-user-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Migration script to backfill userId (UUID) for existing users.
*
* This script finds all users that do not yet have a `userId` and assigns
* a new UUID to each. New users created after the model change will receive
* a `userId` automatically via the schema default.
*
* Usage:
* bun run src/scripts/migrate-user-ids.ts
*/

import mongoose from "mongoose";
import dotenv from "dotenv";
import { randomUUID } from "crypto";

import { User } from "../models/user.model";
import { logger } from "../services/logging/pino-logger";
import * as mongoConnection from "../connections/mongodb.connection";

// Load environment variables
dotenv.config();

async function connectToDatabase() {
try {
logger.info("Initializing MongoDB connection for userId migration");
await mongoConnection.init();
logger.info("Successfully connected to MongoDB");
} catch (error) {
logger.error(error, "Failed to connect to MongoDB:");
process.exit(1);
}
}

async function migrateUserIds() {
const batchSize = 500;

try {
const query = {
$or: [{ userId: { $exists: false } }, { userId: null }, { userId: "" }],
} as const;

const total = await User.countDocuments(query);
logger.info(`Starting userId migration. Users missing userId: ${total}`);

let processed = 0;

while (true) {
const users = await User.find(query).sort({ _id: 1 }).limit(batchSize).exec();

if (users.length === 0) {
break;
}

for (const user of users) {
try {
user.userId = user.userId || randomUUID();
await user.save();
processed += 1;
} catch (error) {
logger.error(error, `Error updating user ${user._id} during userId migration:`);
}
}

logger.info(`Processed ${processed}/${total} users in userId migration (batch size: ${batchSize})`);
}

logger.info(`UserId migration completed. Total users updated with userId: ${processed} (out of ${total})`);
} catch (error) {
logger.error(error, "Error during userId migration:");
process.exit(1);
}
}

async function main() {
try {
await connectToDatabase();
await migrateUserIds();
logger.info("UserId migration script finished successfully");
process.exit(0);
} catch (error) {
logger.error(error, "UserId migration script failed:");
process.exit(1);
} finally {
await mongoose.disconnect();
}
}

// Run the script
main();
146 changes: 146 additions & 0 deletions docs/USER_IDENTITY_MIGRATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
## User Identity Migration Plan

This document describes a phased plan to move MentraOS away from using `email` as the primary user identifier and toward a canonical `userId` (UUID) plus flexible identifiers (email, phone, OAuth, etc.).

---

## Goals

- **Introduce a stable `userId`**: Use a UUID field as the canonical, opaque user identifier.
- **Make `email` optional**: Treat email as one of several identifiers, not the primary key.
- **Support multiple identifiers**: Allow email, phone, OAuth IDs, Supabase IDs, etc. per user.
- **Roll out safely**: Migrate schema, data, and call sites in stages without breaking existing flows.

---

## Phase 1 – Introduce `userId` (no behavior change)

**Objective:** Add the new identity shape while keeping current behavior intact.

- **Model changes**
- Add `userId: string` to `User`:
- `required: true`, `unique: true`, `index: true`
- `default: () => randomUUID()`
- Keep `email` required in the schema for now (no breaking change to existing code).
- **Statics / helpers**
- Add `User.findByUserId(userId)` (not yet widely used).
- **Serialization**
- Update `toJSON` to expose `id = userId || _id` so clients can start relying on the new ID.
- **Data**
- New records get `userId` automatically via default.
- Existing records only get `userId` when they are next saved (no backfill yet).

**Exit criteria**

- New users always have a non-null `userId`.
- All tests pass with the new field present.
- No behavior change for existing flows (email still required and used everywhere).

---

## Phase 2 – Backfill data and start using `userId`

**Objective:** Ensure every user has a `userId` and begin treating it as the primary key internally.

- **Migration**
- Write a one-off script to:
- Scan all users.
- For any document missing `userId`, set `userId = randomUUID()`.
- Run in batches in lower environments first, then production.
- **Indexes**
- Add a unique index on `userId` after the backfill completes.
- **Code changes**
- Prefer `userId` for:
- JWT/session subjects and tokens.
- Service-to-service calls and background jobs.
- Introduce `findByUserId` into new or refactored flows (auth, device pairing, dashboards).
- **Monitoring**
- Add logging/metrics to track usage of `findByUserId` vs `findByEmail`.

**Exit criteria**

- 100% of users have a non-null `userId`.
- Core auth/session paths use `userId` as the canonical identifier.
- No unique index violations on `userId` in production.

---

## Phase 3 – Add identifiers array & deprecate email as key

**Objective:** Allow multiple identifiers per user and move past email as the main identity handle.

- **Schema changes**
- Add `identifiers: { type, value, provider?, verifiedAt? }[]` to the `User` model.
- Add unique sparse index:
- `({ "identifiers.type": 1, "identifiers.value": 1 }, { unique: true, sparse: true })`.
- Make `email` optional and sparse:
- `required: false`
- `unique: true, sparse: true`
- **Helpers**
- Add statics:
- `User.findByIdentifier(type, value)`.
- Add instance helper:
- `user.addIdentifier(type, value, provider?)`.
- **Data migration**
- For each user with an `email` value:
- Ensure an identifier entry `{ type: "email", value: email }` exists.
- Run ahead of enabling the unique index to avoid collisions.
- **Code changes**
- New identity flows:
- Use `findByIdentifier` for Supabase/OAuth/other external IDs.
- Keep `findByEmail` as a compatibility wrapper around `findByIdentifier("email", email)`.
- Gradually refactor existing logic that assumes email is present to tolerate email-less users.

**Exit criteria**

- All email-bearing users have an `"email"` identifier row.
- New identity sources (Supabase, OAuth, etc.) never use email as their primary key.
- Identifier-based lookups are the default for new and refactored features.

---

## Phase 4 – Make email truly optional & clean up

**Objective:** Treat email strictly as a contact method / identifier and remove legacy assumptions.

- **API and types**
- Update public contracts:
- `userId: string` required wherever a user identifier is needed.
- `email?: string` optional in all APIs and TypeScript types.
- Add/adjust endpoints:
- Prefer `userId` in path/query parameters instead of email.
- Keep email-based endpoints only where user experience demands it (e.g. “forgot password”).
- **Refactors**
- Search and update:
- `User.findOne({ email: ... })` → `findByUserId` or `findByIdentifier`.
- Any use of email as a cross-model foreign key → swap to `userId` (or `_id`) where appropriate.
- Mark email-based helpers (`findByEmail`, `findOrCreateUser(email)`) as deprecated in JSDoc.
- Keep only for UX flows that truly need email; remove once all callers are migrated.
- **Indexes and constraints**
- Revisit indexes that depend on email (e.g. `email + runningApps`).
- Adjust or replace them with `userId`-centric constraints if the original constraint assumed email was always present.

**Exit criteria**

- Core flows (auth, device pairing, dashboard, logs) do not rely on email being present.
- All primary identity operations are based on `userId` and/or `identifiers`.
- Remaining email-based behavior is explicitly UX-driven, not foundational to identity.

---

## Rollout and Safety Checklist

**Before enabling identifiers and email-optional:**

- [ ] Phase 1: `userId` field added, tests passing.
- [ ] Phase 2: Backfill complete, `userId` unique index green.
- [ ] Phase 3: Identifiers array populated for all existing emails.

**Before treating email as optional in production:**

- [ ] Auth/session middlewares consume `userId` as the canonical subject.
- [ ] New APIs and internal services use `userId` instead of email for identity.
- [ ] Frontend and mobile clients treat `user.id`/`user.userId` as the stable key.
- [ ] UIs handle users without email gracefully (no crashes, clear messaging).

Once all phases are complete, MentraOS will have a stable, opaque user identity (`userId`) and a flexible identifier system that can grow with new auth providers and contact methods without further schema upheaval.
Loading