- 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
50 KiB
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
productIdis 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_IDacross 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: lysnraiheader - 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"→ immediate400 Bad Requestinstead 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:
- Add products module — new module, no breakage. Seed with LysnrAI product on startup.
- Add
getRequestProductId(req)helper — new file, validates against product cache - Add Fastify
onRequesthook — parse JWT and attachproductIdtoreq.jwtPayloadon every request - Add
productIdto login/register schemas — clients start sending it - File-by-file refactor — replace
PRODUCT_IDimport withgetRequestProductId(req)in routes/repositories - Update repositories to use
getRequestProductConfig()— for product-specific values (trial days, device limits) instead of hardcoded constants - Keep
PRODUCT_IDenv var as fallback — during migration, if no JWT/header, fall back to env var. Remove after all clients are updated. - Update tests — pass
productIdin 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
productIdin 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 toset_{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 devicePUT /api/settings/device/:deviceId— set device overridesDELETE /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
productscontainer 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/
-
Add
planto UserDoc — Storeplanfield (default'free'). Return it in login, register, me, and refresh responses. -
/auth/refreshreturns both tokens — Currently returns only{accessToken}. Change to return{accessToken, refreshToken}(new refresh token issued each time — rotation pattern). -
/auth/meaddsplan— Return{id, email, role, displayName, plan}.
4.2 License Module Enhancements
File: services/platform-service/src/modules/licenses/
-
/licenses/activateissues JWTs — After validating license + registering device, create JWT access + refresh tokens. Response becomes:{ "accessToken": "...", "refreshToken": "...", "userId": "usr_...", "plan": "pro", "deviceId": "abc123" } -
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. -
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:
- Platform-service creates user + generates license key + creates subscription
- All registration details are logged (structured log: userId, email, licenseKey, plan)
- License key is returned in the register API response
- Admin views new registrations in the admin dashboard (users list page)
- 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
planin responses, refresh returning both tokens,productIdin 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, settingsAPI_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:
- Corporate dev (your Mac) — both services on localhost, behind Forcepoint proxy
- 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/.envhasLYSNR_PLATFORM_URL=http://localhost:4003/api~/.LysnrAI/.envhasLYSNR_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.pyandsrc/licensing/license_client.pychange 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/.envfor URLs — defaults just work - Power users can override with
~/.LysnrAI/.envif needed (e.g., self-hosted)
Build process:
scripts/build-desktop.shtakes a--envflag:dev(localhost defaults) orprod(cloud defaults)- The build script patches the default URLs in the Python source before packaging
- Or simpler: ship a
defaults.envinside the app bundle that the config loader reads
iOS / Android:
- Same pattern:
env.dev(localhost) vsenv.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 needget_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— KeepUserInDB(used byget_current_userdep). RemoveUserCreate,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.py—get_current_userJWT verification (validates tokens from platform-service, doesn't issue them)clients/openai_client.py— Azure OpenAI for session compositioncloud/cosmos.py— Cosmos DB client for sessions + transcripts containers onlyconfig.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.