learning_ai_common_plat/docs/BACKEND_TO_PLATFORM_SERVICE_MIGRATION.md
saravanakumardb1 60617ab050 refactor(platform-service): replace PRODUCT_ID with getRequestProductId(req) in all modules
- 26 files updated: all repositories accept productId parameter instead of env var
- All route handlers extract productId via getRequestProductId(req) (JWT → header → env fallback)
- Repositories: auth, flags, audit, notifications, licenses, plans, referrals, usage, subscriptions, invitations
- Routes: all above + promos, items, memory, public, ratelimit, stripe
- lib/webhooks.ts: dispatchWebhook accepts optional productId parameter
- Stripe webhook handler uses metadata-based productId (no client JWT available)
- Ratelimit default config uses DEFAULT_PRODUCT_ID at startup
- 166 tests pass, tsc --noEmit clean
2026-02-15 14:29:11 -08:00

50 KiB
Raw Blame History

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:

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:

// 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

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:

// 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

// 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):

// 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

// 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 userIds. 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:

{
  "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:

// 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:

    {
      "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 23 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 7694). 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:
    _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:

# 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:

# 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

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

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

Verification: python -m pytest tests/ backend/tests/ -v --tb=short

Phase 4: Cross-repo verification

- Platform-service: pnpm test (170+ tests expected)
- Backend: pytest (remaining session/transcript/export tests)
- Admin dashboard: npx tsc --noEmit
- User dashboard: npx tsc --noEmit
- Tracker dashboard: npx tsc --noEmit
- Local smoke test: 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.pyget_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.