# Backend → Platform-Service Migration Plan

> **Status:** Final v4a — ready for implementation  
> **Author:** Cascade  
> **Date:** 2026-02-15  
> **Repos:** `learning_ai_common_plat` (platform-service), `learning_voice_ai_agent` (LysnrAI)

---

## 1. Goal

Eliminate duplicated platform logic from the Python backend by having **all clients talk to platform-service directly** for auth, licenses, notifications, usage, settings, and user profiles. The Python backend shrinks to a pure "Dictation API" — only LysnrAI-specific session/transcript/export + OpenAI composition logic remains.

Additionally, **refactor `productId` from a server-level env var to a request-level value**, enabling a single platform-service instance to serve multiple products (LysnrAI, MindLyst, future products). The combination of `userId + productId` scopes all data. A new **products registry module** gives admins a central place to create and manage products, validate productIds, and store per-product configuration.

### Before

```
iOS / Android / Desktop ──→ Python backend (:8000) ──→ Cosmos DB     [auth, license, settings, etc.]
iOS / Android / Desktop ──→ Python backend (:8000) ──→ Cosmos DB     [sessions, transcripts]
Web dashboards ──────────→ platform-service (:4003) ──→ Cosmos DB    [auth, license, etc.]
Web dashboards ──────────→ Python backend (:8000) ───→ Cosmos DB     [sessions, transcripts]

productId = server-level env var (hardcoded "lysnrai" at startup)
```

**Problems:**

- Auth, license, notifications, usage, settings, users exist in **both** the Python backend and platform-service
- `productId` is hardcoded per deployment — can't serve MindLyst from the same instance
- A user with accounts in both LysnrAI and MindLyst can't be distinguished

### After

```
ALL clients ──→ platform-service (:4003) ──→ Cosmos DB    [products, auth, license, notifications, usage, settings]
ALL clients ──→ Python backend (:8000) ───→ Cosmos DB     [sessions, transcripts, export]

productId = from JWT token (authenticated) OR X-Product-Id header (unauthenticated)
```

**Python backend routes after migration:** `sessions.py`, `transcripts.py`, `export.py` (3 files)  
**Deleted from backend:** `auth.py`, `license.py`, `notifications.py`, `usage.py`, `settings.py`, `users.py`, `email/` (7 files + email dir, ~2,480 lines including tests)

---

## 2. Multi-Product `productId` Design (Foundational Change)

### 2.0 The Problem

Today, `PRODUCT_ID` is loaded once at startup from an env var (`loadProductIdentity()` → `"lysnrai"`). Every module imports it and hardcodes it into Cosmos queries and JWT tokens. This means:

- **One platform-service deployment = one product** — can't serve MindLyst from the same instance
- A user who uses both LysnrAI and MindLyst gets their data mixed (same `userId`, no product scoping at the request level)
- 107 references to `PRODUCT_ID` across 29 files in platform-service

### 2.1 The Solution: Products Registry + Request-Level `productId`

**A `products` container in Cosmos DB is the single source of truth for all registered products.** Admin creates a product → gets a `productId` → hands it to the dev team → dev team uses it across the entire stack.

**`productId` flows from the client on every request:**

```
Admin creates product:         POST /api/products { productId: "lysnrai", displayName: "LysnrAI", ... }
                                           ↓
Dev team hardcodes in client:  productId = "lysnrai"
                                           ↓
Login/Register request body:   { email, password, productId: "lysnrai" }
                                           ↓
Platform-service validates:    productCache.has("lysnrai") → ✅ (rejects unknown productIds)
                                           ↓
JWT token payload:             { sub, email, role, productId: "lysnrai", type: "access" }
                                           ↓
All subsequent requests:       platform-service reads productId from JWT
                                           ↓
Cosmos queries:                WHERE c.productId = @productId  (from JWT, not env var)
```

**For unauthenticated requests** (license activation, public roadmap, etc.):

- Client sends `X-Product-Id: lysnrai` header
- Platform-service reads from header, validates against product registry

### 2.2 Products Module — Central Product Registry

**New directory:** `services/platform-service/src/modules/products/`

```
products/
├── types.ts        — ProductDoc schema, Zod validation
├── repository.ts   — Cosmos CRUD (products container)
├── cache.ts        — In-memory Map<productId, ProductDoc>, refreshed on startup + admin writes
└── routes.ts       — CRUD endpoints (admin-only for write, public for read)
```

**ProductDoc schema:**

```typescript
export interface ProductDoc {
  id: string; // e.g. "lysnrai"
  productId: string; // same as id (partition key)
  displayName: string; // e.g. "LysnrAI"
  licensePrefix: string; // e.g. "LYSNR"
  packageName: string; // e.g. "com.bytelyst.LysnrAI"
  defaultPlan: 'free' | 'pro'; // plan assigned on registration
  trialDays: number; // e.g. 14
  deviceLimits: {
    // max devices per plan
    free: number;
    pro: number;
    enterprise: number;
  };
  websiteUrl: string; // e.g. "https://lysnn.com"
  status: 'active' | 'disabled'; // disabled products reject all requests
  createdAt: string;
  updatedAt: string;
}
```

**Endpoints:**

- `GET /api/products` — List all products (public, cached)
- `GET /api/products/:id` — Get one product (public, cached)
- `POST /api/products` — Create product (admin-only)
- `PUT /api/products/:id` — Update product (admin-only, refreshes cache)

**In-memory cache:**

```typescript
// products/cache.ts
const productCache = new Map<string, ProductDoc>();

export async function loadProductCache(): Promise<void> {
  const all = await repository.getAll();
  productCache.clear();
  for (const p of all) productCache.set(p.id, p);
}

export function getProduct(productId: string): ProductDoc | undefined {
  return productCache.get(productId);
}

export function isValidProduct(productId: string): boolean {
  return productCache.has(productId);
}
```

**Cache is loaded on startup** (`server.ts` calls `loadProductCache()` before listening) and **refreshed on admin writes** (create/update call `loadProductCache()` after mutation).

**Seed on first startup:** If the products container is empty, seed with LysnrAI defaults from env/config (backward compat).

### 2.3 Implementation: `getRequestProductId(req)` Helper

**New file:** `services/platform-service/src/lib/request-context.ts`

```typescript
import { BadRequestError } from './errors.js';
import { isValidProduct, getProduct } from '../modules/products/cache.js';

/**
 * Extract and validate productId from the request.
 * Priority: JWT token > X-Product-Id header > env fallback (dev only)
 * Rejects unknown or disabled products.
 */
export function getRequestProductId(req: FastifyRequest): string {
  // 1. From JWT (set during login/register)
  let id = req.jwtPayload?.productId;

  // 2. From header (unauthenticated requests)
  if (!id) {
    const header = req.headers['x-product-id'];
    if (typeof header === 'string' && header.length > 0) id = header;
  }

  // 3. Fallback to env var (backward compat during migration, dev only)
  if (!id) {
    const envFallback = process.env.PRODUCT_ID;
    if (envFallback) id = envFallback;
  }

  if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)');

  // Validate against product registry
  if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`);
  const product = getProduct(id)!;
  if (product.status === 'disabled') throw new BadRequestError(`Product ${id} is disabled`);

  return id;
}

/** Get the full product config for the current request's productId */
export function getRequestProductConfig(req: FastifyRequest): ProductDoc {
  const id = getRequestProductId(req);
  return getProduct(id)!;
}
```

**Benefits of validation:**

- Typo like `productId: "lysnraii"` → immediate `400 Bad Request` instead of silently creating orphan data
- Admin can disable a product → all requests for it are rejected instantly
- Modules can read product config (trial days, device limits) without hardcoding

**Refactor pattern for all 29 files:**

```typescript
// BEFORE (107 occurrences):
import { PRODUCT_ID } from '../../lib/product-config.js';
// ...
productId: PRODUCT_ID,

// AFTER:
import { getRequestProductId } from '../../lib/request-context.js';
// ...
const productId = getRequestProductId(req);
```

### 2.4 JWT Changes

**File:** `services/platform-service/src/modules/auth/jwt.ts`

```typescript
// BEFORE: productId from env var
export async function createAccessToken(payload: { sub, email, role }) {
  return new SignJWT({ ...payload, productId: PRODUCT_ID, type: 'access' })
    .setIssuer(PRODUCT_ID)  // ← hardcoded issuer

// AFTER: productId from caller (login/register request body)
export async function createAccessToken(payload: { sub, email, role, productId }) {
  return new SignJWT({ ...payload, type: 'access' })
    .setIssuer('bytelyst-platform')  // ← generic issuer (accepts all products)
```

**`verifyToken`** — remove `issuer: PRODUCT_ID` validation (was rejecting tokens from other products):

```typescript
// BEFORE:
const { payload } = await jwtVerify(token, getSecret(), { issuer: PRODUCT_ID });

// AFTER:
const { payload } = await jwtVerify(token, getSecret(), { issuer: 'bytelyst-platform' });
```

### 2.5 Login/Register Schemas Add `productId`

```typescript
// auth/types.ts
export const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
  productId: z.string().min(1), // ← NEW: required
});

export const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  displayName: z.string().min(1),
  role: z.enum(['admin', 'viewer', 'user']).default('user'),
  productId: z.string().min(1), // ← NEW: required
});
```

### 2.6 User Identity Model

User docs are **product-scoped** (matches current `auth/repository.ts` behavior). A user registering for both LysnrAI and MindLyst with the same email gets **two separate user documents** with different `userId`s. See §4.6 for the design rationale.

```
users container:          { id: "usr_abc", productId: "lysnrai", email: "bob@test.com", ... }
                          { id: "usr_xyz", productId: "mindlyst", email: "bob@test.com", ... }  ← separate user
settings container:       { id: "set_lysnrai_usr_abc", productId: "lysnrai", userId: "usr_abc", ... }
licenses container:       { id: "lic_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" }
subscriptions container:  { id: "sub_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" }
usage_daily container:    { id: "usg_2026-02-15_usr_abc", productId: "lysnrai", userId: "usr_abc" }
```

**Key rule:** All containers are scoped by `productId`. Cross-product account linking is a future consideration.

### 2.7 Migration Path for the 107 References

The refactor is mechanical but must be done carefully:

1. **Add products module** — new module, no breakage. Seed with LysnrAI product on startup.
2. **Add `getRequestProductId(req)` helper** — new file, validates against product cache
3. **Add Fastify `onRequest` hook** — parse JWT and attach `productId` to `req.jwtPayload` on every request
4. **Add `productId` to login/register schemas** — clients start sending it
5. **File-by-file refactor** — replace `PRODUCT_ID` import with `getRequestProductId(req)` in routes/repositories
6. **Update repositories to use `getRequestProductConfig()`** — for product-specific values (trial days, device limits) instead of hardcoded constants
7. **Keep `PRODUCT_ID` env var as fallback** — during migration, if no JWT/header, fall back to env var. Remove after all clients are updated.
8. **Update tests** — pass `productId` in test requests

**Estimated scope:** 29 files, ~107 line changes (mostly mechanical find-replace) + 1 new module (~150 lines).

---

## 3. Contract Parity — Backend vs Platform-Service

### 3.1 Auth Contract Gaps

> **Note:** All clients must also start sending `productId` in login/register request bodies (see §2.4).

| Aspect                        | Backend (current)                                                                             | Platform-service (current)       | Action                                                               |
| ----------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- |
| Field casing                  | `access_token`, `refresh_token`                                                               | `accessToken`, `refreshToken`    | **Clients adopt camelCase**                                          |
| User `name` field             | `user.name`                                                                                   | `user.displayName`               | **Clients adopt `displayName`**                                      |
| User `plan` field             | `user.plan` returned                                                                          | Not returned                     | **Platform-service adds `plan` to auth responses**                   |
| `/auth/refresh` response      | Returns both `access_token` + `refresh_token`                                                 | Returns only `accessToken`       | **Platform-service returns both tokens**                             |
| `/auth/register` side effects | Creates billing subscription (14-day Pro trial) + generates license key + sends welcome email | Just creates user                | **Platform-service adds register hook** (see §4.3)                   |
| `/auth/resend-key`            | Exists                                                                                        | Does not exist                   | **Removed** — admin manually emails license keys via admin dashboard |
| `/auth/me` response           | `{id, email, name, role, plan, ...}`                                                          | `{id, email, role, displayName}` | **Platform-service adds `plan`**                                     |

### 3.2 License Contract Gaps

| Aspect                        | Backend (current)                                                               | Platform-service (current)       | Action                                                    |
| ----------------------------- | ------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------- |
| URL prefix                    | `/api/license/`                                                                 | `/api/licenses/`                 | **Clients adopt `/licenses/`**                            |
| `/licenses/activate` response | Returns `{access_token, refresh_token, user_id, plan, device_id}` — issues JWTs | Returns `LicenseDoc` — no JWTs   | **Platform-service issues JWTs on activate**              |
| Account lockout               | IP-based failed attempt tracking                                                | Not implemented                  | **Platform-service adds lockout** (use rate-limit module) |
| `/license/refresh` endpoint   | Separate refresh for license-based sessions                                     | Not needed — use `/auth/refresh` | **Clients switch to `/auth/refresh`**                     |
| `/license/status` response    | Includes device list + usage                                                    | Simpler status only              | **Platform-service enriches response**                    |

### 3.3 Notifications — Already Aligned

Backend routes are already marked `deprecated=True` with comments saying "Dashboard clients now use platform-service". The backend just proxies to Cosmos DB with the same schema. Platform-service `modules/notifications/` is the source of truth.

**Action:** Delete backend `notifications.py`. Update iOS/Desktop to call platform-service directly for device registration and notification preferences.

### 3.4 Usage — Already Aligned

Backend routes are already marked `deprecated=True` with comments saying "These routes duplicate the Billing Service". Platform-service `modules/usage/` has full parity (list, summary, upsert, check-limits).

**Action:** Delete backend `usage.py`. Update Desktop `api_sync.py` to call platform-service.

### 3.5 Settings — Needs New Module

Backend `settings.py` (170 lines) provides user settings CRUD with per-device overrides. **No equivalent exists in platform-service.**

**Current backend settings model** (`userId + productId` scoped):

- Global settings: hotkey, language, cleanup, sound, clipboard, custom vocabulary/instructions, template
- Per-device overrides: same fields, merged on read
- Doc ID: `set_{userId}` → changes to `set_{productId}_{userId}`

**Action:** Create `modules/settings/` in platform-service. Product-agnostic — each product stores its own settings schema per user. The settings "shape" (which keys exist, defaults) is product-defined, but the CRUD logic (get, put, device overrides) is shared.

Settings doc structure:

```json
{
  "id": "set_lysnrai_usr_abc",
  "productId": "lysnrai",
  "userId": "usr_abc",
  "settings": {
    /* product-specific key-value pairs */
  },
  "deviceOverrides": {
    "device123": {
      /* partial overrides */
    }
  },
  "updatedAt": "..."
}
```

Platform-service endpoints:

- `GET /api/settings` — get settings (productId from JWT)
- `PUT /api/settings` — update settings (merge)
- `GET /api/settings/device/:deviceId` — get resolved settings for device
- `PUT /api/settings/device/:deviceId` — set device overrides
- `DELETE /api/settings/device/:deviceId` — clear device overrides

### 3.6 Users — Merge into Auth Module

Backend `users.py` (82 lines) has one endpoint: `PUT /users/:userId/plan` — updates a user's plan tier. This is called by Stripe webhooks after payment events.

**Action:** Add `PUT /auth/users/:userId/plan` to the auth module (or keep it internal via `X-Internal-Key`). The user profile (displayName, email, etc.) is already managed by the auth module. Plan field is being added to UserDoc (§4.1). This endpoint just needs to exist for Stripe webhook callbacks.

Platform-service already has `modules/stripe/routes.ts` which can call the auth repository directly — so the `users.py` endpoint may not even be needed as a REST route. The Stripe webhook handler can update the user plan inline.

---

## 4. Platform-Service Changes (common-plat repo)

### 4.0 Products Module (NEW)

**New directory:** `services/platform-service/src/modules/products/` (see §2.2 for full design)

This module is the **first thing built** in Phase 0 because `getRequestProductId()` depends on the product cache for validation.

**Key behaviors:**

- On startup: load all products into memory cache
- On admin create/update: refresh cache
- Seed on empty: if products container has 0 docs, create LysnrAI with defaults from env
- Register in `server.ts`: `await app.register(productRoutes, { prefix: '/api' })` (before other modules)
- Create `products` container in Cosmos DB initialization

**How other modules use product config:**

```typescript
// licenses/routes.ts — device limit check
import { getRequestProductConfig } from '../../lib/request-context.js';

const product = getRequestProductConfig(req);
const maxDevices = product.deviceLimits[license.plan];  // not hardcoded

// auth/routes.ts — register hook
const product = getRequestProductConfig(req);
await createSubscription({ plan: product.defaultPlan, trialDays: product.trialDays, ... });
```

### 4.1 Auth Module Enhancements

**File:** `services/platform-service/src/modules/auth/`

1. **Add `plan` to UserDoc** — Store `plan` field (default `'free'`). Return it in login, register, me, and refresh responses.

2. **`/auth/refresh` returns both tokens** — Currently returns only `{accessToken}`. Change to return `{accessToken, refreshToken}` (new refresh token issued each time — rotation pattern).

3. **`/auth/me` adds `plan`** — Return `{id, email, role, displayName, plan}`.

### 4.2 License Module Enhancements

**File:** `services/platform-service/src/modules/licenses/`

1. **`/licenses/activate` issues JWTs** — After validating license + registering device, create JWT access + refresh tokens. Response becomes:

   ```json
   {
     "accessToken": "...",
     "refreshToken": "...",
     "userId": "usr_...",
     "plan": "pro",
     "deviceId": "abc123"
   }
   ```

2. **Add IP-based lockout to activate** — Use Fastify `request.ip`. Track failed attempts in-memory (same pattern as backend). After 5 failures in 5 minutes → 15-minute lockout.

3. **Enrich `/licenses/status/:key`** — Include device list and usage summary in response.

### 4.3 Register Hook — Subscription + License (Log-Only)

**File:** `services/platform-service/src/modules/auth/routes.ts` (register endpoint)

After creating the user, read product config and add best-effort post-registration steps:

```
1. Read product config → products cache (trialDays, defaultPlan, licensePrefix)
2. Create subscription (product.trialDays trial, product.defaultPlan) → subscriptions module
3. Generate license key (product.licensePrefix) → licenses module
4. Log registration event (userId, email, licenseKey, plan, productId) → structured log
```

Steps 2–3 are **best-effort** — failures are logged but don't block registration. The license key is returned in the register response so the client can display it immediately.

### 4.4 No Email Module — Log-Only + Admin Manual Flow

Email sending is **removed entirely**. The backend's email module (`backend/src/email/`) was never wired up (no `azure-communication-email` in requirements.txt, no `AZURE_EMAIL_CONNECTION_STRING` configured). No emails were ever actually sent.

**Post-registration flow:**

1. Platform-service creates user + generates license key + creates subscription
2. All registration details are **logged** (structured log: userId, email, licenseKey, plan)
3. License key is returned in the register API response
4. Admin views new registrations in the **admin dashboard** (users list page)
5. Admin copies the license key and emails the user manually from their Gmail/personal email

**Why this is fine for pre-launch / early beta:**

- Low volume — admin can handle manual emails
- No email infrastructure to set up, configure, or pay for
- No email deliverability issues (SPF, DKIM, spam filters)
- Can add automated email later as a separate feature when volume demands it

**`/auth/resend-key` endpoint:** Not needed. Admin looks up the license key in the admin dashboard and emails it manually.

### 4.5 BILLING_INTERNAL_KEY Guard — Must Be Removed for Direct Client Access

**Critical gap:** `server.ts` currently wraps usage, subscriptions, plans, and licenses behind a `BILLING_INTERNAL_KEY` guard (lines 76–94). When `BILLING_INTERNAL_KEY` is set, these routes require `X-Internal-Key` header — which mobile/desktop clients can't provide.

**After migration, mobile/desktop clients call these endpoints directly.** The guard must be removed or replaced with standard JWT auth.

**Action:** Remove the `billingScope` wrapper. All these routes should use normal JWT auth (Bearer token), same as auth routes. The `X-Internal-Key` pattern was a legacy from when billing-service was separate.

### 4.6 User Doc: Product-Scoped (Design Decision)

**Current state:** `auth/repository.ts` queries users with `WHERE c.productId = @productId AND c.email = @email`. This means user docs are **product-scoped** — a user registering for both LysnrAI and MindLyst gets **two separate user documents**.

**Decision: Keep product-scoped users.** This matches the existing code and is the simplest model. Reasons:

- Simpler — no cross-product account linking logic needed
- Each product manages its own users independently
- If a user has both LysnrAI and MindLyst accounts with the same email, they're treated as separate users
- Cross-product account linking ("single sign-on across products") can be added later when there's actual demand

**Updated §2.5 user model:**

```
users container:          { id: "usr_abc", productId: "lysnrai", email: "bob@test.com", ... }
                          { id: "usr_xyz", productId: "mindlyst", email: "bob@test.com", ... }  ← separate user
settings container:       { id: "set_lysnrai_usr_abc", productId: "lysnrai", userId: "usr_abc", ... }
licenses container:       { id: "lic_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" }
```

### 4.7 Tests

- **Products:** CRUD tests, cache invalidation on write, seed-on-empty behavior, unknown productId rejection
- **Auth:** Update existing tests for `plan` in responses, refresh returning both tokens, `productId` in requests
- **Licenses:** Add test for JWT issuance on activate, lockout behavior, device limit from product config
- **Settings:** New module tests (CRUD, device overrides, productId scoping)
- **Register hook:** Test subscription + license are called with product config values (mocked)

---

## 5. Client Changes (LysnrAI repo)

### 5.1 iOS — `AuthService.swift`

**File:** `mobile_app/ios/LysnrAI/Auth/AuthService.swift`

| Change             | Before                                 | After                                                                      |
| ------------------ | -------------------------------------- | -------------------------------------------------------------------------- |
| `baseURL` for auth | `http://127.0.0.1:8000`                | Read `PLATFORM_SERVICE_URL` from `env.dev`                                 |
| `TokenResponse`    | `access_token`, `refresh_token`        | `accessToken`, `refreshToken`                                              |
| `AuthUser`         | `name: String`, `plan: String`         | `displayName: String`, `plan: String`                                      |
| `RefreshResponse`  | `access_token`, `refresh_token`        | `accessToken`, `refreshToken`                                              |
| Refresh body key   | `"refresh_token"`                      | `"refreshToken"`                                                           |
| Login body         | `{email, password}`                    | `{email, password, productId: "lysnrai"}`                                  |
| Register body      | `{email, name, password}`              | `{email, displayName, password, productId: "lysnrai"}`                     |
| Register response  | `UserResponse` (then auto-login)       | `{accessToken, refreshToken, user, licenseKey}` — no separate login needed |
| Settings           | Reads from `API_BASE_URL/api/settings` | Reads from `PLATFORM_SERVICE_URL/api/settings`                             |

**Note:** iOS still calls the Python backend for sessions/transcripts (different base URL). Two base URLs:

- `PLATFORM_SERVICE_URL` → auth, license, notifications, usage, settings
- `API_BASE_URL` → sessions, transcripts, export

### 5.2 Android — `AuthViewModel.kt`

**File:** `mobile_app/android/app/src/main/java/com/saravana/lysnrai/ui/auth/AuthViewModel.kt`

Same field renames as iOS. Point auth calls to `PLATFORM_SERVICE_URL`. Add `productId: "lysnrai"` to login/register bodies.

### 5.3 Desktop — Python clients

Desktop clients currently read `LYSNR_API_URL` env var (defaults to `http://localhost:8000/api`). After migration, they need **two** URLs:

| Env Var              | Default (dev)               | Used For                                          |
| -------------------- | --------------------------- | ------------------------------------------------- |
| `LYSNR_API_URL`      | `http://localhost:8000/api` | Sessions, transcripts, export (Python backend)    |
| `LYSNR_PLATFORM_URL` | `http://localhost:4003/api` | Auth, license, settings, usage (platform-service) |

| File                              | Change                                                                                                                                                                                                                           |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/auth/auth_client.py`         | Add `_get_platform_url()` reading `LYSNR_PLATFORM_URL`. All auth calls use platform URL. Fields: `accessToken`, `refreshToken`, `displayName`. Add `productId: "lysnrai"` to login/register bodies.                              |
| `src/licensing/license_client.py` | Same: use `LYSNR_PLATFORM_URL`. Path: `/api/licenses/activate` (not `/api/license/activate`). Response fields: camelCase. Add `X-Product-Id: lysnrai` header (unauthenticated). Remove `/license/refresh` — use `/auth/refresh`. |
| `src/cloud/api_sync.py`           | `report_usage()` → use `LYSNR_PLATFORM_URL` for `/api/usage`. Transcript sync stays on `LYSNR_API_URL`.                                                                                                                          |

### 5.4 Env Config Updates

**`mobile_app/ios/env.dev.example`:**

```
API_BASE_URL=http://127.0.0.1:8000
PLATFORM_SERVICE_URL=http://127.0.0.1:4003
```

**`mobile_app/android/env.dev.example`:**

```
API_BASE_URL=http://10.0.2.2:8000
PLATFORM_SERVICE_URL=http://10.0.2.2:4003
```

**Desktop `~/.LysnrAI/.env` and `.env.example`:**

```
LYSNR_API_URL=http://localhost:8000/api
LYSNR_PLATFORM_URL=http://localhost:4003/api
```

### 5.5 Environment Detection — Corp (localhost) vs Production (cloud)

**The problem:** The Mac desktop app runs in two contexts:

1. **Corporate dev (your Mac)** — both services on localhost, behind Forcepoint proxy
2. **Production (end-user Macs)** — services hosted on Azure, accessed over public internet

The desktop app must know which URLs to hit without the user manually editing config files.

**Solution: Build-time environment baking + runtime override**

```
┌──────────────────────────────────────────────────────────────┐
│  URL Resolution Order (desktop app)                          │
│                                                              │
│  1. ~/.LysnrAI/.env  (user override — always wins)           │
│  2. Environment variable  (LYSNR_PLATFORM_URL, LYSNR_API_URL)│
│  3. Built-in defaults  (baked at build/release time)          │
└──────────────────────────────────────────────────────────────┘
```

**Development (corp Mac):**

- `~/.LysnrAI/.env` has `LYSNR_PLATFORM_URL=http://localhost:4003/api`
- `~/.LysnrAI/.env` has `LYSNR_API_URL=http://localhost:8000/api`
- Both services run locally, only outbound Azure calls go through proxy
- Developer runs `run-local-all-services.sh` → everything works

**Production (release build):**

- Built-in defaults in `src/auth/auth_client.py` and `src/licensing/license_client.py` change to:
  ```python
  _DEFAULT_PLATFORM_URL = "https://platform.bytelyst.com/api"   # Azure Container Apps
  _DEFAULT_API_URL = "https://api.lysnrai.com/api"              # Azure Container Apps
  ```
- End users don't need `~/.LysnrAI/.env` for URLs — defaults just work
- Power users can override with `~/.LysnrAI/.env` if needed (e.g., self-hosted)

**Build process:**

- `scripts/build-desktop.sh` takes a `--env` flag: `dev` (localhost defaults) or `prod` (cloud defaults)
- The build script patches the default URLs in the Python source before packaging
- Or simpler: ship a `defaults.env` inside the app bundle that the config loader reads

**iOS / Android:**

- Same pattern: `env.dev` (localhost) vs `env.prod` (cloud URLs)
- Xcode schemes / Gradle build flavors select the right env file
- Already standard mobile practice

**Key principle:** Developers override locally via `~/.LysnrAI/.env`. Released apps ship with production URLs baked in. No config needed for end users.

---

## 6. Backend Cleanup (LysnrAI repo)

### 6.1 Delete Routes

| File                                  | Lines | Reason                                                    |
| ------------------------------------- | ----- | --------------------------------------------------------- |
| `backend/src/routes/auth.py`          | 264   | Moved to platform-service                                 |
| `backend/src/routes/license.py`       | 436   | Moved to platform-service                                 |
| `backend/src/routes/notifications.py` | 149   | Moved to platform-service                                 |
| `backend/src/routes/usage.py`         | 76    | Moved to platform-service                                 |
| `backend/src/routes/settings.py`      | 170   | Moved to platform-service                                 |
| `backend/src/routes/users.py`         | 82    | Moved to platform-service (Stripe webhook handles inline) |
| `backend/src/email/service.py`        | 92    | Removed (was never functional)                            |
| `backend/src/email/templates.py`      | 134   | Removed (was never functional)                            |

### 6.2 Update `main.py`

Remove router registrations:

```python
# DELETE these lines:
app.include_router(auth.router, prefix=prefix)
app.include_router(license.router, prefix=prefix)
app.include_router(notifications.router, prefix=prefix)
app.include_router(usage.router, prefix=prefix)
app.include_router(settings.router, prefix=prefix)
app.include_router(users.router, prefix=prefix)
```

Remove imports:

```python
# DELETE from imports:
auth, license, notifications, settings, usage, users
```

### 6.3 Delete/Update Tests

| File                                 | Action                                      |
| ------------------------------------ | ------------------------------------------- |
| `backend/tests/test_auth.py`         | Delete (auth now in platform-service tests) |
| `backend/tests/test_license.py`      | Delete                                      |
| `backend/tests/test_settings.py`     | Delete                                      |
| `backend/tests/test_usage.py`        | Delete                                      |
| `backend/tests/test_billing_flow.py` | Review — may reference auth endpoints       |

### 6.4 Clean Up Dead Code

- `backend/src/auth/jwt.py` — Keep if sessions/transcripts still need `get_current_user`. The JWT verification logic stays (backend still validates tokens), but token **issuance** (`create_access_token`, `create_refresh_token`) is no longer needed.
- `backend/src/auth/password.py` — Delete (password hashing moved to platform-service).
- `backend/src/models/user.py` — Keep `UserInDB` (used by `get_current_user` dep). Remove `UserCreate`, `UserResponse`.
- `backend/src/clients/platform_client.py` — Remove notification and audit methods (dead after route deletion). Keep if other routes still use it.
- `backend/src/clients/billing_client.py` — Review if still needed (register was the main consumer).
- `backend/src/middleware/rate_limit.py` — Review — may reference auth/license routes.
- `backend/src/middleware/usage_limits.py` — Review — was used by license status route.

### 6.5 JWT Shared Secret

**Critical:** Platform-service and Python backend must share the same `JWT_SECRET` so tokens issued by platform-service are valid when the backend verifies them for session/transcript endpoints.

Both already read from `JWT_SECRET` env var / Azure Key Vault (`lysnr-jwt-secret`). No code change needed, just ensure deployment config is aligned.

**Note on issuer:** The backend's `decode_token()` in `backend/src/auth/jwt.py` does **NOT** validate the JWT issuer field — it only verifies the signature and expiration. So when platform-service changes its issuer from `'lysnrai'` to `'bytelyst-platform'`, the backend is **unaffected**. No backend JWT code change needed for the issuer change.

---

## 7. Networking & Deployment Topology

### 7.1 Corporate Proxy Constraint

The development Mac sits behind a **Forcepoint corporate proxy** that only allows Azure-bound traffic. This affects the architecture:

```
✅ localhost → Azure Cosmos DB           (proxy allows)
✅ localhost → Azure OpenAI              (proxy allows)
✅ localhost → Azure Blob Storage        (proxy allows)
❌ localhost → external SMTP server      (proxy BLOCKS)
❌ localhost → non-Azure external APIs   (proxy BLOCKS)
```

**Impact:** All outbound calls from both services are Azure-bound → proxy-safe. No email module needed.

### 7.2 Local Development Topology

Both services run on localhost. The desktop app, iOS Simulator, and web dashboards all connect locally:

```
Desktop app (Python)  ──→ localhost:4003 (platform-service)  [auth, license, settings, usage]
Desktop app (Python)  ──→ localhost:8000 (Python backend)    [sessions, transcripts, export]
iOS Simulator         ──→ 127.0.0.1:4003                    [auth, license, settings, usage]
iOS Simulator         ──→ 127.0.0.1:8000                    [sessions, transcripts, export]
Web dashboards        ──→ localhost:4003                     [via @bytelyst/* packages]
Web dashboards        ──→ localhost:8000                     [sessions, transcripts API routes]

platform-service (:4003) ──→ Azure Cosmos DB    (outbound, proxy-safe)
Python backend (:8000)   ──→ Azure Cosmos DB    (outbound, proxy-safe)
Python backend (:8000)   ──→ Azure OpenAI       (outbound, proxy-safe)
```

**Startup:** `run-local-all-services.sh` starts both services. No changes needed to the dev workflow.

### 7.3 Production Topology (Azure)

```
Mobile / Desktop ──→ https://platform.bytelyst.com  (Azure Container Apps — platform-service)
Mobile / Desktop ──→ https://api.lysnrai.com        (Azure Container Apps — Python backend)
Web dashboards   ──→ Azure Static Web Apps / App Service  [SSR + API routes]

All services ──→ Azure Cosmos DB (private VNet)
Backend      ──→ Azure OpenAI (private endpoint)
```

**Production URLs (pending domain setup):**

- `platform.bytelyst.com` — shared across all products (LysnrAI, MindLyst, future)
- `api.lysnrai.com` — LysnrAI-specific dictation API (sessions, transcripts, export)
- Custom domains on Azure Container Apps with managed SSL certificates

No proxy issues in production — all Azure-to-Azure traffic.

### 7.4 JWT Cross-Service Validation

**Critical for the two-service localhost setup:**

Platform-service issues JWTs → Desktop/iOS sends them to Python backend for session/transcript calls → Backend must accept them.

Requirements:

- **Same `JWT_SECRET`** — both services read from the same env var / Key Vault secret (`lysnr-jwt-secret`)
- **Same algorithm** — both use HS256
- **Issuer — not a concern** — backend's `decode_token()` does not validate the issuer field, only signature + expiration

**Backend change needed:** None. Tokens from platform-service (with any issuer) are accepted as long as the `JWT_SECRET` matches.

---

## 8. Execution Order

### Phase 0: Products module + productId refactor — foundational (common-plat)

This phase must come first because everything else builds on the product registry and request-level `productId`.

```
Commit 1:  feat(platform-service): add products module (types, repository, cache, routes)
           — Create products container in Cosmos, seed with LysnrAI defaults
Commit 2:  feat(platform-service): add getRequestProductId() + getRequestProductConfig() helpers
           — Validates productId against product cache, rejects unknown/disabled products
Commit 3:  feat(platform-service): add Fastify onRequest hook to parse JWT → req.jwtPayload
Commit 4:  refactor(platform-service): auth/jwt.ts — productId from caller, issuer → 'bytelyst-platform'
Commit 5:  refactor(platform-service): auth routes + types — add productId to login/register schemas
Commit 6:  refactor(platform-service): replace PRODUCT_ID with getRequestProductId(req) in all modules
           (29 files, ~107 changes — mechanical find-replace, done in batches)
Commit 7:  refactor(platform-service): remove BILLING_INTERNAL_KEY guard from server.ts
           — All billing-scope routes now use standard JWT auth
Commit 8:  test(platform-service): update all tests to pass productId in requests + product tests
```

**Verification:** `pnpm test` — all 158+ tests pass with request-level productId.

### Phase 1: Platform-service feature additions (common-plat)

No email module — registration logs events for admin to act on manually.

```
Commit 9:  feat(platform-service): add plan field to auth UserDoc + all auth responses
Commit 10: feat(platform-service): auth/refresh returns both accessToken + refreshToken
Commit 11: feat(platform-service): licenses/activate issues JWT tokens + IP lockout
           — Device limits read from product config, not hardcoded
Commit 12: feat(platform-service): register hook — subscription + license (reads trialDays/plan from product config)
Commit 13: feat(platform-service): add settings module (userId + productId scoped, device overrides)
Commit 14: feat(platform-service): add user plan update to auth module (for Stripe webhooks)
Commit 15: test(platform-service): add tests for all new functionality
```

**Progress (incremental commits):**

- [x] Commit 9 — `a9ac953`
- [x] Commit 10 — `17772ed`
- [x] Commit 11 — `5e38342`
- [x] Commit 12 — `a264538`
- [x] Commit 13 — `0c3c109`
- [x] Commit 14 — `84681cb`
- [x] Commit 15 — `8a7a049`

**Post-implementation review fixes:**

- `a699dd9` — make register provisioning truly best-effort; fix multi-product Stripe handling baseline
- `b987dec` — harden Stripe webhook routing when product metadata is missing; normalize plan handling

**Verification:** `pnpm test` — all tests pass.

### Phase 2: Client migration (LysnrAI)

```
Commit 16: refactor(ios): AuthService → platform-service (camelCase, displayName, productId, PLATFORM_SERVICE_URL)
Commit 17: refactor(android): AuthViewModel → platform-service (same changes)
Commit 18: refactor(desktop): auth_client + license_client + api_sync → platform-service
Commit 19: chore: update env.dev.example files with PLATFORM_SERVICE_URL
```

**Progress (incremental commits):**

- [x] Commit 16 — `26d2a8b` (learning_voice_ai_agent)
- [x] Commit 17 — `63aa2ae` (learning_voice_ai_agent)
- [x] Commit 18 — `759955f` (learning_voice_ai_agent)
- [x] Commit 19 — included in Commit 18 (env examples already had PLATFORM_SERVICE_URL)

**Verification:** iOS builds (`xcodebuild`), Desktop tests (`pytest tests/`). ✅

### Phase 3: Backend cleanup (LysnrAI) ✅

```
Commit 20: refactor(backend): delete auth, license, notifications, usage, settings, users routes + email/
Commit 21: refactor(backend): clean up dead models, tests, middleware, clients
Commit 22: chore(backend): update main.py, .env.example, requirements.txt
```

**Progress (incremental commits):**

- [x] Commits 20-22 (combined) — `bd68e89` (learning_voice_ai_agent)
  - Deleted 6 routes, 2 models, 7 test files (2,637 lines removed)
  - Updated main.py, conftest.py, .env.example

**Verification:** `python -m pytest backend/tests/ -v` — 51 tests pass ✅

### Phase 4: Cross-repo verification ✅

```
- Platform-service: pnpm test — 189 tests pass ✅
- Backend: pytest — 51 tests pass ✅
- Admin dashboard: npx tsc --noEmit — clean ✅
- User dashboard: npx tsc --noEmit — clean ✅
- Tracker dashboard: npx tsc --noEmit — clean ✅
- Local smoke test: pending (start both services, login via desktop app, create session, verify JWT cross-service flow)
```

---

## 9. Risk Mitigation

| Risk                                              | Mitigation                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| JWT secret mismatch between services              | Both read `JWT_SECRET` from same Key Vault secret (`lysnr-jwt-secret`). Verify in deployment config.                                                                                                                                                                                                                                                                                      |
| JWT issuer change                                 | Platform-service changes issuer to `'bytelyst-platform'`. Backend is unaffected — its `decode_token()` doesn't validate issuer.                                                                                                                                                                                                                                                           |
| Mobile app can't reach platform-service           | `PLATFORM_SERVICE_URL` is a runtime config (env.dev), not compiled. Easy to change without app rebuild.                                                                                                                                                                                                                                                                                   |
| No automated emails                               | By design — admin manually emails users from admin dashboard. Automated email can be added later when volume demands it.                                                                                                                                                                                                                                                                  |
| Unknown productId silently creates orphan data    | Product registry validates every `productId` against Cosmos `products` container. Unknown IDs → 400 Bad Request.                                                                                                                                                                                                                                                                          |
| productId refactor breaks existing web dashboards | Phase 0 keeps `PRODUCT_ID` env-var as fallback. Dashboards have their own `product-config.ts` that reads `PRODUCT_ID` for direct Cosmos queries (e.g., `repositories/users.ts`). These keep working unchanged — they use env-var for their own queries, and send it via `@bytelyst/*` client packages when calling platform-service. Fallback removed only after all clients are updated. |
| 107 PRODUCT_ID references — regression risk       | Mechanical refactor done file-by-file. Each batch is a separate commit. Tests run after each batch.                                                                                                                                                                                                                                                                                       |
| Breaking existing platform-service tests          | Phase 0 Commit 8 updates all test fixtures to include `productId`. Run tests after each commit.                                                                                                                                                                                                                                                                                           |
| Product cache stale after admin update            | Cache is refreshed after every admin write (create/update). Cache miss falls through to direct Cosmos read.                                                                                                                                                                                                                                                                               |

---

## 10. What the Backend Becomes (Post-Migration)

**3 route files (down from 13):**

| Route                    | Purpose                                                      | Stays because                                             |
| ------------------------ | ------------------------------------------------------------ | --------------------------------------------------------- |
| `sessions.py` (19KB)     | Dictation sessions CRUD, AI composition, SSE streaming, sync | LysnrAI-specific product logic + Azure OpenAI integration |
| `transcripts.py` (8.8KB) | Voice transcript storage, search                             | LysnrAI-specific product logic                            |
| `export.py` (3.1KB)      | Session/transcript data export                               | Tied to product data models                               |

**Remaining support code:**

- `auth/deps.py` — `get_current_user` JWT verification (validates tokens from platform-service, doesn't issue them)
- `clients/openai_client.py` — Azure OpenAI for session composition
- `cloud/cosmos.py` — Cosmos DB client for sessions + transcripts containers only
- `config.py` — Backend-specific settings (OpenAI endpoint/key, Cosmos, CORS)
- `middleware/` — Request ID, CORS

**Lines of code removed:** ~1,400 (routes) + ~230 (email) + ~170 (settings) + ~80 (users) + ~600 (tests) = **~2,480 lines deleted**

**The Python backend becomes a minimal "Dictation API"** — 3 route files, ~31KB of product logic, no auth/platform concerns.

---

## 11. Future Considerations

- **`organizations.py`** (479 lines) — Product-agnostic team management. Move to platform-service.
- **`webhooks.py`** (256 lines) — Event dispatch. Move to platform-service.
- **`api_tokens.py`** (3.6KB) — API key CRUD. Move to platform-service.
- **`themes.py`** (4.4KB) — UI theme preferences. Move to platform-service (partially exists in admin-dashboard already).
- **Products module extensions** — Product-level feature flags (enable/disable features per product), branding config (logo, colors for white-label), webhook URLs per product, rate limit config per product.
- **Cross-product SSO** — If users want one login for all ByteLyst products, add account linking (shared email → linked userId across products). Not needed pre-launch.
- **Automated email** — When user volume outgrows manual admin emails, add Azure Communication Services or SendGrid module. Products table can store sender email and template overrides.
- **Eventually the Python backend could become an Azure Function** — just sessions + transcripts + OpenAI, no persistent server needed. Cold start is acceptable for dictation workloads.
