Platform Admin Layer
Who this guide is for: Embay Consulting internal staff with a platform user account. The platform is not accessible to tenant admins or learners.
Overview
app.embaylms.com is the Embay staff platform administration domain. It provides cross-tenant operations — tenant provisioning, status management, migration monitoring, billing oversight, and support tooling — against the global schema. Platform users are a separate identity from tenant users; they are stored in embaylms_global.platform_users.
The platform does not surface tenant-scoped features (courses, enrollments, catalog, SCORM, reports, learning paths, etc.). Those are always accessed at the tenant’s own subdomain (e.g. acme.embaylms.com).
Platform User Roles
| Role | Who | What they can do |
|---|---|---|
super_admin | Engineering leads, ops | Everything: create/delete/suspend tenants, manage platform users and roles, override tenant config, export audit logs, impersonate any tenant user |
support_agent | Customer support staff | Read all tenant records (status, config, migration state). Impersonate a tenant user for debugging (1-hour session, audit-logged). Cannot create/delete tenants or manage platform users. |
billing_admin | Finance / accounts | Read billing and subscription data across all tenants. Export invoices. No access to tenant user data or migration tools. |
Getting Access
Platform accounts are created by a super_admin via /platform/users. There is no self-signup.
- Ask a
super_adminto invite your Embay email address - You will receive an email with a one-time setup link
- Set your password and enable TOTP MFA (required for all platform users)
- Navigate to
https://app.embaylms.comand sign in
Bootstrap note: The very first
super_adminmust be created via the seed script:pnpm tsx infra/scripts/seed-platform-admin.tsThis is a one-time operation. See the runbook in
docs/enablement/runbooks/for production bootstrap steps.
Forgot Your Password? (LMS-366)
Platform accounts have self-service password reset:
- On the sign-in page (
https://app.embaylms.com/platform/login), click Forgot password? - Enter your platform account email and click Send reset link
- Check your inbox for an email titled “Password reset — EmbayLMS platform” and click Reset password
- Choose a new password (minimum 8 characters) and confirm it
- Sign in with the new password — the old one stops working immediately
| Behaviour | Detail |
|---|---|
| Link lifetime | 1 hour — request a new link if it expires |
| Email language | Matches the locale of the page you requested from (EN / FR-CA) |
| Unknown emails | The page always shows the same success message — it never reveals whether an account exists |
| Rate limit | 5 requests per minute per IP address |
| Audit | Both the request and the completed reset are written to the audit log |
| MFA | Resetting the password does not reset TOTP MFA. If you also lost your authenticator, a super_admin must clear your MFA enrollment |
Troubleshooting
- No email arrives: check spam, confirm you used the exact email on your platform account, and verify with a
super_adminthat the account is active — deactivated accounts never receive reset emails. - “This reset link is invalid or has expired”: the link is older than 1 hour, was already used after a newer one was issued, or was generated for a tenant account. Request a fresh link from
/platform/forgot-password.
Dashboard (/platform/dashboard)
Shows aggregate platform health. Visible to all platform roles.
| Metric | Description |
|---|---|
| Total Tenants | All tenants in the global registry |
| Active Tenants | Tenants with status = active |
| Migrations Done | Count of completed migration jobs |
| Migrations Failed | Count of failed jobs (highlighted in red if > 0) |
Tenant Management (/platform/tenants)
Access:
super_adminfor mutations. All roles can view.
Viewing Tenants
Lists all tenants with display name, subdomain, status, region, latest migration state, and creation date.
Tenant Detail (/platform/tenants/{slug}) — tabbed workspace (LMS-773)
Access: all platform roles can open the page; write actions are gated per tab (below). Three read surfaces are narrower than the page — the Administrators list in Key Contacts (tenant user data) plus the Recent Activity card and the whole Audit tab (audit data) are visible to
super_adminandsupport_agentonly. Abilling_adminsees none of them; they are omitted rather than rendered empty, and the audit trail is withheld server-side, not merely hidden in the UI. LMS-775.
Opening a tenant shows the central per-tenant management workspace, organized into
tabs. The tab is part of the URL (?tab=…), so any tab can be bookmarked or linked
in a support thread. Log in to tenant and the Edit drawer (the global tenant
record: display name, custom domain, status) both live in the page header on every
tab, Overview included. The Overview tab’s content is read-only — anything that
changes entitlements, subscription, or exports lives on its own tab.
| Tab | What’s there | Who can see it | Who can change things |
|---|---|---|---|
| Overview | Identity (region, created, custom domain, billing, health badge), usage metric cards, configuration snapshot, recent activity, Key Contacts, support-access panel | All roles — but the Administrators list within Key Contacts and the Recent Activity card are super_admin/support_agent only, and the support-access panel is super_admin only | Nobody — read-only |
| Entitlements | Live gated-feature flags vs tier defaults + effective caps | All roles | super_admin, billing_admin |
| Subscription | Commercial summary + the four subscription actions (tier / trial / limits / billing state) | All roles | super_admin, billing_admin |
| Health | The three customer-health measures behind the R/A/G signal, with thresholds | All roles | Nobody — read-only |
| Audit | This tenant’s audit trail, filterable, with CSV export | super_admin, support_agent (tab hidden otherwise) | Nobody — read-only |
| Data Export | Full tenant data-export jobs + downloads | super_admin (tab hidden otherwise) | super_admin |
Two more tabs depend on the tenant kind. Every customer tenant has an Academy tab (its
staff’s Embay Academy adoption, all roles, read-only). The platform Academy tenant has a
Curriculum tab instead (super_admin only). Both are described under
Embay Academy below.
Usage metrics (LMS-590)
Metrics come from a scheduled hourly rollup into tenant_metrics, never a live
query across tenant schemas (ADR-013; the isolation rule in CLAUDE.md §3). The
freshness line under the cards shows when the tenant was last computed — a tenant
created minutes ago reads “Metrics pending first sync” until the next sweep.
The Users card carries two different “active” numbers; they answer different questions and must not be substituted for each other:
| Field | Means | Use it for |
|---|---|---|
| Licensed | Active, non-deleted accounts — provisioned seats | Seat-cap headroom |
| Signed in (30d) | Accounts that actually signed in during the window | The billing definition (PRD §7.6) — matches the tenant billing page |
| Active (30d) | Accounts whose record changed in the window | Engagement only. Moves when an admin edits a profile or SCIM syncs, so it can exceed “Signed in” without anyone logging in |
A large gap between Signed in and Active usually means provisioning activity (a bulk import or SCIM run) rather than real usage.
Storage is the tenant’s content usage — the de-duplicated sum of module asset sizes, the same figure the plan-cap meter enforces — shown against the plan’s cap.
The rollup holds counts only, no PII.
Key Contacts (LMS-440 / LMS-773). The Overview tab’s Key Contacts section shows who to talk to about a tenant without signing in to it:
- Administrators — every tenant user with the
owneroradminrole, with name, email (click to compose), last login, and active status. Soft-deleted users are excluded. The list is read from the tenant schema live; if it is briefly unavailable the section shows an empty state rather than failing the page. This is tenant user data, so it is shown tosuper_adminandsupport_agentonly —billing_adminsees the billing contact but not this list (LMS-775). - Billing contact — the invoice recipient (
billing email) and PO number from the subscription record. When no billing email is set, invoices fall back to the earliest active owner/admin — the section says so instead of showing a blank.
Customer Health tab (LMS-773)
The Health tab explains the R/A/G health dot shown on the tenant list — same inputs, same thresholds, never a second opinion. Four measures, each with its measured value and the rule it is judged against:
| Measure | Rule |
|---|---|
| Activity recency | ≤ 7 days healthy · 8–30 days needs attention · > 30 days (or never active) at risk — the only measure that can turn the signal red |
| Seat utilization | Active users (30d) over the seat cap → needs attention. Unlimited caps show “no impact” |
| Completion trend | Completions in the last 30 days more than 10% below the prior per-30d average (days 31–90) → needs attention |
Academy onboarding (LMS-1070) | After day 14, no Owner or Admin has completed the Administrator track in Embay Academy → needs attention. Never at risk. Not judged for tenants created before 2026-09-25, in a tenant’s first 14 days, or on the Academy tenant itself |
This is deliberately a lightweight signal (PRD §6.3.1) — the weighted composite health score is future CS tooling, not the console. “No data yet” means the tenant has not had its first hourly metrics sweep.
Audit tab (LMS-773)
Access:
super_adminandsupport_agent. The tab does not appear forbilling_admin, and?tab=auditfalls back to Overview for that role (LMS-775).
The Audit tab embeds the cross-tenant audit viewer pre-filtered to this tenant: filter by action or resource type, paginate, and Export CSV for the current page (SOC 2 evidence). Use Open in cross-tenant viewer for investigations that span tenants.
Feature Entitlements tab (super_admin for changes) — LMS-373
The tenant page’s Entitlements tab shows the tenant’s plan tier, seat/course/storage limits, API monthly cap, and each gated feature flag (SSO/SAML, SCIM, API access, custom branding, certificates, advanced automations) with its current On/Off state and whether entitlements are enforced for the tenant.
- Per-flag overrides. A
super_admincan toggle an individual flag on or off outside the tier default — e.g. enable SSO temporarily for a security review. Every override requires a reason, and the change is audit-logged (actor, before/after, reason). - Overrides persist until the tier changes — using Change plan tier resets the flags to the new tier’s defaults.
- Tenants with entitlements not enforced (grandfathered) show all features available regardless of flags.
Subscription tab (super_admin, billing_admin) — LMS-373 / LMS-773
The Subscription tab replaces the former “Manage subscription” side drawer. It shows a read-only Commercial Summary — plan · status, caps, trial end, billing cycle, current period start, next renewal, cancellation intent, scheduled plan/seat changes, billing email, PO number, and the Stripe customer/subscription ids (Stripe is the commercial source of truth, ADR-016) — beside the management form with the same four actions as before:
| Action | Effect |
|---|---|
| Change plan tier | Resets caps and feature flags to the new tier’s defaults |
| Grant / extend trial | Sets the tier + trialing status for 1–90 days |
| Adjust limits & features | Override seat cap, storage cap, monthly API cap, or individual feature flags |
| Set billing state | active / past_due for dunning — does not change tenant login status |
Every action requires a reason and is audit-logged with before/after snapshots. Other platform roles see the commercial summary read-only.
Pay by purchase order (LMS-783)
Below the management form, the Pay by purchase order card shows the tenant’s
invoice-billing request — PO number, billing contact, terms requested, note, date —
and the current arrangement (charge_automatically or send_invoice · Nd). A new
request also lands on the platform bell (billing.invoice_billing_requested,
billing_admin broadcast).
Action (billing_admin, super_admin) | Effect |
|---|---|
| Approve with net 30 / 45 / 60 | Switches the Stripe subscription to collection_method: send_invoice with days_until_due = the terms, mirrors both on billing_subscriptions, emails the tenant’s billing contact |
| Decline (pending request) | Records the decline; collection stays on card; emails the contact with your note |
| Revoke — back to card (approved arrangement) | Switches Stripe back to charge_automatically; emails the contact |
Every decision needs a reason (audit) and an optional note (sent to the tenant). If Stripe refuses the collection-method change, nothing is recorded — the mirror is never allowed to disagree with Stripe. A tenant with no Stripe subscription yet (Free) is mirrored locally only; the card says so. Dunning for an invoice-billed tenant uses the net terms as the grace period instead of the 14-day card-retry window.
Negotiated e-commerce platform fee (LMS-1050)
Access: all platform roles can read the panel. Recording and ending a rate is
billing_adminandsuper_adminonly.
The platform fee is the share EmbayLMS keeps on a tenant’s own course sales through Stripe
Connect. Every tenant starts on the rate of its plan tier. When a customer negotiates a
different rate, record it on the tenant. The Subscription tab’s E-commerce platform fee
panel shows the rate in force today, where it comes from, and the history, newest first.
The rate for a sale is resolved in this order (packages/api/src/lib/ecommerce-fee.ts):
| Order | Source | Shown in the panel as |
|---|---|---|
| 1 | The tenant’s negotiated term, if one is active and today is inside its window | negotiated term |
| 2 | The per-tier override set on the Settings page | tier override (Platform → Settings) |
| 3 | The code default for the tier: Free 10%, Starter 8%, Growth 5%, Enterprise 3% | tier default |
| 4 | 8% when the tier is unknown | fallback (tier unknown) |
Because step 1 wins, a tier change never moves a negotiated rate, and neither does an edit to the per-tier table.
To record a negotiated rate:
- Open the tenant, then the Subscription tab, and find the
E-commerce platform feepanel. - Click
Record a negotiated rate. - Fill in the form:
| Field | Required | Description |
|---|---|---|
Rate (% of net sale) | Yes | 0 to 50, up to two decimals (for example 2.5) |
Starts | Yes | First day the rate applies. A future date does not apply early |
Ends (blank = until replaced) | No | Must be after the start. The rate stops applying the moment this date passes |
Authorising HubSpot deal | One of these two | The id of the deal that authorises the rate |
…or why there is no deal | One of these two | At least 10 characters. Fill in one or the other, never both |
What was agreed | Yes | The agreement in words. Audit-logged |
- Click
Record rate. The next sale is charged at the new rate. There is nothing to create in Stripe: the platform sets the fee on every checkout.
| Action | How | Effect |
|---|---|---|
| Record | Steps above | Creates an active term |
| Supersede | Record a new rate while one is active | In one write, the old term becomes superseded and the new one becomes active. The old row stays in the history |
| Revoke | Click End early on the active term and give a reason | The term becomes revoked. The next sale falls back to the tier rate |
Provenance is mandatory: a term is refused without a reason, and without either the
authorising deal or the reason there is none. A term that reaches its end date is stamped
expired by the nightly sweep. A rate cannot be recorded for a demo, sandbox or Academy
tenant.
Every write is audit-logged with before and after values: ecommerce_fee_term_recorded
and ecommerce_fee_term_revoked on the tenant, ecommerce_fee_tier_set and
ecommerce_fee_tier_reset for the per-tier table. Each order also stores the rate it was
charged and its source.
The per-tier table is on the Settings page (/platform/settings), in the
E-commerce take-rate card. Only a super_admin can change a tier rate (Save) or return
it to the code default (Reset); other roles see it read-only. Its Negotiated column
lists the tenants on a negotiated rate, so you know who a tier change will not affect.
The tenant sees its effective rate, read-only, on its own Billing page. It never sees the
reason, the deal or the history. Operator detail: docs/runbooks/negotiated-terms.md §7.
Live from Stripe (LMS-774)
Below the summary, the tab reads live from Stripe (the commercial source of truth):
- MRR — normalized monthly figure (
unit amount × billed quantity, annual prices ÷ 12; same formula as the HubSpot CRM mirror), with the per-user rate shown underneath - Last payment — amount and date of the most recent paid invoice
- Subscription — live status, renewal date, cycle, and a “cancelling” marker when cancel-at-period-end is set
- Default card — brand, last four, expiry
- Recent invoices — up to 12, with status, amount, and a link to the Stripe-hosted PDF
The Billed Usage card shows the LMS-side numbers the pushed billed quantity derives
from: active users in the current billing period (the actual Stripe period, not the
calendar month) and licensed users, next to the quantity currently on the Stripe
subscription item. A gap between active users and the Stripe quantity is normal
mid-period — it closes at the next invoice.upcoming push or daily backstop sweep.
Subscription History (below the two widgets) tables every change event on the
subscription with its date: creation, quantity adjustments, plan/price changes,
cancellation scheduled or removed, status transitions, and renewals — read from the
Stripe customer.subscription.* events feed. Stripe’s events API retains roughly
30 days; the subscription’s original creation (and cancellation) dates are still
shown as anchor rows read from the subscription record itself.
When “Live from Stripe” shows Unavailable: the tenant has no Stripe customer yet (typical for Free tenants) or Stripe could not be reached. Nothing is wrong with the page — the commercial summary above still shows the last webhook-reconciled state.
Data Export tab (super_admin only) — LMS-379
The Data Export tab produces a full export of a tenant’s data (users, courses, enrollment/completion records) — for contract-end offboarding or a support investigation (§6.3.1).
- Click Export tenant data. A job is queued and an audit entry
(
tenant_export_requested) records who requested it. - The worker gathers the data, writes a JSON document to the exports bucket, and emails you when it’s ready.
- Click Refresh to update the list, then Download on a completed job. The link is a
short-lived presigned URL (≤15 minutes, CLAUDE.md §4.4); each download is audit-logged
(
tenant_export_downloaded). Re-click to get a fresh link after it expires.
| State | Meaning |
|---|---|
queued / running | The export is in progress. |
completed | Ready — a Download button appears (shows file size). |
failed | Something went wrong (reason shown), e.g. exports storage not configured. |
Signing in to a Tenant (super_admin only) — LMS-437
A super_admin can sign in to any active tenant as a full administrator
without waiting for a customer access request. On the tenant detail page click
Log in to tenant.
- The action records an audit entry (
platform_tenant_login) tying your platform identity to the target tenant — this is the trace of which super_admin entered which tenant and when. - You are redirected to the tenant (e.g.
acme.embaylms.com) and signed in as a synthetic platform-support administrator. A navy banner across the top reads “You are signed in to {tenant} as Embay platform support”. - You have the full tenant-admin permission set — you can do anything a tenant admin can.
- Click Return to platform in the banner to sign out of the tenant and go back to the platform console (your platform session is preserved).
| Property | Detail |
|---|---|
| Who | super_admin only. Support agents use consent-based access (future) instead. |
| Tenant visibility | You do not appear in the tenant’s user table — the platform-support session has no tenant user record. |
| Attribution | Actions inside the tenant are audit-logged with your platform user id as the actor. |
| Availability | Only active tenants — reactivate a suspended tenant first. |
| Exit | ”Return to platform”, or simply signing out of the tenant. |
This is distinct from user impersonation (logging in as a specific tenant user); platform-support login acts as a generic tenant administrator.
Creating a Tenant (super_admin only)
- Click New Tenant
- Fill in the form:
| Field | Required | Description |
|---|---|---|
| Display Name | Yes | Human-readable name (e.g. “Acme Corp”) |
| Subdomain Slug | Yes | Lowercase, alphanumeric + hyphens. Becomes {slug}.embaylms.com |
| Region | Yes | Default: ca-central-1 |
| Custom Domain | No | e.g. learn.acme.com |
| Create as a demo tenant | No | The tenant’s kind is chosen at creation and cannot be toggled later (LMS-812). Demo tenants unlock the seeded demo content set and can never carry a paid subscription (ADR-021 billing guardrail) |
- Click Create Tenant
The system creates the tenant record (status: provisioning) and queues a worker job to:
- Create the
embaylms_tenant_{slug}PostgreSQL schema - Register
{slug}.embaylms.comin Vercel
If Redis is unavailable, run provisioning manually:
pnpm tsx infra/scripts/provision-tenant.ts --tenant {slug}Suspending a Tenant (super_admin only)
Click Suspend. Sets status: suspended — all logins at {slug}.embaylms.com are blocked. No data is deleted.
Activating a Tenant (super_admin only)
Click Activate on a suspended tenant to restore access.
Deleting a Tenant (super_admin only)
Soft-deletes the tenant record (status: deleted). The PostgreSQL schema and S3 data are not immediately purged — a separate manual purge step is required. Deletion is audit-logged and irreversible via the UI.
Demo Tenants (LMS-809 / LMS-811 / LMS-812)
Access:
super_adminonly for all demo actions.
A demo tenant is a tenant created with the demo kind (see the Create Tenant form above — the kind is fixed at creation, not toggled later). Demo tenants exist to carry the seeded “Northpoint Manufacturing” demo content set (ADR-021) for sales demos and evaluation, and are barred from paid subscriptions by the billing guardrail.
Demo Seed panel
The tenant detail page of a demo tenant shows a Demo Seed panel:
- Seed / Reseed queues a demo-seed job on the worker. Reseeding is destructive — it wipes the tenant’s content (the audit log is preserved) and re-applies the fixture set deterministically. The panel warns before queueing.
- Job status — the panel polls the current job (queued → running → step-by-step progress → done/failed) and shows the last job’s outcome. Failures surface an operator-safe error message.
- Seeded demo users share one password (
DEMO_SEED_PASSWORDon the worker; fixture default when unset — see CLAUDE.md §13.2).
Operational details, prerequisites (worker S3 write access) and troubleshooting:
docs/runbooks/demo-tenant-seeding.md. Coverage decisions (what the seed does and does
not demonstrate) are tracked in the seed manifest
(packages/api/src/lib/demo-seed/manifest.ts, ADR-021).
Embay Academy (LMS-1063 / LMS-1066 / LMS-1069)
Embay Academy is Embay’s own training portal for customers’ staff. The console covers three things: the Academy tenant itself, publishing its curriculum, and seeing how customers use it.
The platform Academy tenant (LMS-1063)
Access: created by an operator script, not from the console. All platform roles see it in the tenant list.
The Academy runs in one tenant at academy.embaylms.com. It is its own tenant kind, next
to customer and demo tenants, marked by the is_platform_academy flag.
| Property | Detail |
|---|---|
| One only | The database allows a single tenant with the flag, and the provisioning script refuses to run a second time |
| How it is created | infra/scripts/provision-platform-academy.ts, run once by an operator. It is a dry run by default; add --execute to create the tenant. The academy slug is reserved, so signup and the New Tenant form both refuse it. Runbook: docs/runbooks/platform-academy.md |
| Never billed | It has no subscription record. The console’s subscription actions, signup and the Stripe webhook all refuse to attach one, and a negotiated fee cannot be recorded for it |
| All features on | It resolves to the full entitlement without a plan |
| Not a customer | The lifecycle engine, the Stripe orphan sweep, signup repair and the billing metrics endpoint skip it. It cannot be flagged as a demo tenant |
| In the tenant list | The Status column shows an Academy badge beside the tenant’s status |
| Tabs | It has a Curriculum tab (super_admin only) and no Academy tab |
| Registration | Open to outside people such as consultants and partners, with email verification and CAPTCHA on. Customers’ staff arrive from their own portal instead |
A customer that runs its own academy is an ordinary billed tenant. The flag is for Embay’s Academy only.
Importing the Academy curriculum (LMS-1066)
Access:
super_adminonly. The tab exists only on the platform Academy tenant.
The curriculum is written in the repository under content/academy/: the tracks in
academy.yaml, one folder per course under courses/, one per exam under exams/. The
importer publishes it into the Academy tenant. Nothing is edited in the Academy’s own admin
screens.
Run it once after the tenant is created, and again each time a merged change touches
content/academy/:
- In the console sidebar, click
Tenantsand open the tenant with theAcademybadge. - Open the
Curriculumtab. - Click
Import curriculum. The button readsImport in progress…until the run ends, and a second import cannot start while one is queued or running. - The worker runs the import. The panel updates every 4 seconds while it runs; click
Refreshto update it by hand. - Read the result line: the status (
queued,running,succeededorfailed), the request and finish times, and the created / updated / unchanged totals. Below it, each course key is listed with its version number and outcome.
| Outcome | Meaning |
|---|---|
created | The course did not exist and was published |
updated | The course changed. A new version was published and its learners moved onto it with their progress |
unchanged | Nothing to do |
The import never deletes anything, so it is safe to run twice and has no confirmation step.
The Academy tenant must be active. Each request is audit-logged (academy_import_requested).
| Symptom | Cause | Fix |
|---|---|---|
| ”Import could not be queued. Please try again.” | The worker queue (Redis) could not be reached | Check the worker and REDIS_URL, then click Import curriculum again |
Status failed with “The curriculum failed validation” and a list of errors | A curriculum file is invalid | Fix the file in content/academy/, merge, and import again. pnpm tsx infra/scripts/import-academy.ts --check validates the files without touching a database |
Status failed with “The import failed; see the worker log” | The run stopped for another reason | Find academy_import.failed in the worker log, fix the cause, and import again. A failed import is never retried automatically |
The same import can be run without the console: pnpm tsx infra/scripts/import-academy.ts.
Academy adoption (LMS-1069)
Access: all platform roles. Named people are shown to
super_adminandsupport_agentonly. Abilling_adminsees counts, never names.
There are two views. Both are read-only.
Across all customers: click Academy in the console sidebar (/platform/academy).
The page shows tenant names and counts only, never a person.
| Section | What it shows |
|---|---|
Fleet funnel | Totals across customers: eligible staff, entered, in progress, track completed, certified, each as a share of eligible staff |
Customers by Administrator onboarding | One row per customer that has eligible staff, with a milestone badge (Admin track done, Entered, not done or Not entered), the eligible, entered and track-completed counts, the date the Administrator track was completed, and the last activity. The tenant name opens that tenant’s Academy tab |
Courses — completion and drop-off | Per Academy course: enrolled, completed, completion rate, and the lesson where most people stop |
Certification exams | Per exam: attempts, passed attempts, pass rate, people, people certified |
Most-missed questions | The exam questions most often answered wrong, with the miss rate. Use it to find unclear lessons or questions |
The customer rows come from the hourly metrics rollup (the line above the table says when it last synced). The course, exam and question sections are read from the Academy tenant. If the Academy tenant does not exist yet, the page says so and shows no course data.
For one customer: open the tenant and click its Academy tab
(/platform/tenants/{slug}?tab=academy).
| Section | What it shows | Who sees it |
|---|---|---|
Adoption funnel | Eligible staff, entered, in progress, track completed, certified | All roles |
Administrator onboarding | Done, with the date and the number of days after the tenant was created, or Not yet. The milestone is an Owner or Admin completing the Administrator track | All roles |
Tracks | Per track: enrolled, completed, median days to complete, exam average, pass rate, active certifications, certifications expiring within 30 days | All roles |
People | Each staff member who entered the Academy: name, role, tracks, progress, last activity, certification, expiry, best score, attempts | super_admin and support_agent only |
For a billing_admin the People table is left out by the server, not just hidden on the
page. The line at the bottom of the tab says how fresh the progress data is: it is
refreshed on every lesson and hourly.
The Overview tab also has a small Academy card (entered out of eligible, the Administrator
track date, active certifications), and the Health tab has the Academy onboarding measure.
“Eligible staff” means active users whose role comes with an Academy track: Owner, Admin, E-commerce Admin, Group Admin, Manager, Instructor, and any custom role that can manage users. Learners are not counted. Three of these adoption figures are also mirrored to HubSpot: see the HubSpot CRM mirror guide.
Support: Tenant Impersonation (/platform/tenants/{slug}/impersonate)
Access:
super_adminandsupport_agent
Allows an Embay staff member to log in to a tenant’s context as a specific user, for debugging purposes.
- Find the tenant in the tenant list
- Click Impersonate
- Select the target tenant user (by email)
- Confirm — you are redirected to
{slug}.embaylms.comwith a 1-hour impersonation session
Audit trail: Every impersonation login is written to audit_log with:
actor_id: platform user IDis_impersonation: trueimpersonated_user_id: target userimpersonated_by_role: your platform role
Constraints:
- Impersonation sessions expire after 1 hour and cannot be extended
- An impersonation session cannot itself perform impersonation
support_agentimpersonation sessions are read-only within the tenant (no mutations)
Platform User Management (/platform/users)
Access:
super_adminonly
- Invite a new platform user by email — sends a one-time set-password link.
- Add a new platform user directly (LMS-438) — generates a temporary password shown once for you to hand off securely. The new user is forced to change it on first sign-in (a full-screen prompt blocks the console until they do). Use this when email delivery is unavailable or you need to bootstrap an account immediately.
- Assign or change roles
- Deactivate a platform user (immediately revokes access)
- View login history per user
Impersonating a Platform User (LMS-439)
Access:
super_adminonly
For debugging another Embay staff member’s experience, hover a row in
/platform/users and click Impersonate. Your session is swapped to that user
and an amber banner appears at the top of the console; click Exit impersonation
to return to your own account. You cannot impersonate yourself or a deactivated
user. Every impersonation start is written to the audit log
(platform_impersonation_started) with your id as impersonatedBy.
This impersonates a platform user on
app.embaylms.com— it is distinct from entering a tenant (see Tenant access above).
Errors Console & Platform Notifications (/platform/errors) — LMS-600/601
Access:
super_adminandsupport_agentcan view; status changes and identity resolution aresuper_adminonly.
The Errors console surfaces the self-healing error pipeline (ADR-018): user-facing tenant errors are captured first-party, deduplicated into error groups, ticketed at runtime in Linear, and triaged by an automated coding agent. The console is the human window into that loop.
The Errors list
/platform/errors lists error groups (not individual occurrences), filterable
by status and tenant slug. Each row shows the tenant slug, environment,
occurrence and affected-user counts, first/last seen, and the linked Linear bug.
Group statuses: New, Triaging, Fix in progress, Awaiting decision
(the agent escalated — a human call is needed), Resolved, Ignored, and
Regressed (a resolved error came back).
Status actions (super_admin, audit-logged): Resolve an open group,
Ignore noise, or Reopen a resolved/ignored group. Each action is also
pushed to the linked Linear issue (Resolve → completed, Ignore → cancelled,
Reopen → reopened). If the platform app has no LINEAR_AGENT_API_KEY, the
status still saves locally and the console warns that the Linear issue was not
updated.
Identity resolution (audit-logged)
Error rows are pseudonymous — they carry the user’s UUID, tenant slug, and
browser/OS only, never email, name, or IP. On a group’s detail page a
super_admin can explicitly resolve a captured UUID to a person (name, email,
role) when needed for support follow-up. Every resolution is written to the
audit log.
The notifications bell
The platform console header shows a notifications bell — the first in-app notification path for Embay platform users. It signals pipeline events that need attention: an error group awaiting a decision, a fix deployed, or a regression. Clicking a notification deep-links to the relevant error group.
For pipeline operations (enabling capture, agent caps, the safety envelope,
callback secrets, and incident handling), see the runbook:
docs/runbooks/self-healing-pipeline.md.
Email reputation alarms (LMS-705)
An hourly worker sweep computes each email provider’s bounce and
complaint rates over a rolling 24-hour window (from the webhook-fed
email_metrics counters) and raises a platform bell notification when a
threshold is crossed. Both providers are checked — the dormant one included —
and rates are only computed once a provider has sent at least 100 emails
in the window. The same alert level is not re-raised within a 6-hour cooldown.
| Signal | Warning | Critical | AWS review line (for reference) |
|---|---|---|---|
| Bounce rate | 2.5% | 4% | 5% |
| Complaint rate | 0.05% | 0.08% | 0.1% |
Thresholds sit deliberately below the AWS review lines even though sending runs on Resend — SES is the retained return path (ADR-020), and a domain whose reputation degraded on Resend would arrive at SES already in trouble.
When an alarm fires, check:
- The suppression list (
email_suppressions) — is it growing? A burst of hard bounces usually means a bad address batch (e.g. a CSV import with stale emails). - The Resend dashboard — per-message bounce/complaint detail and any provider-side status notices.
- Recent bulk sends — a tenant’s large notification blast, import-triggered invites, or a scheduled report run that targeted a stale audience is the most common cause. Identify the tenant and pause the source if needed.
What the Platform Does NOT Include
The following are not available at app.embaylms.com. Always access them at the tenant’s subdomain:
- Course catalog and course editor
- Enrollments and completions
- Learning paths and skills
- SCORM content upload and launch
- ILT sessions and quizzes
- Tenant user management (inviting learners, setting tenant roles)
- Reports and certificates
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
Redirected to login page on app.embaylms.com | No platform user account, or session expired | Sign in, or ask a super_admin to create your account |
| Signed in but immediately redirected away | Account exists in a tenant schema, not in platform_users | A super_admin must create you a platform account via /platform/users |
Tenant shows provisioning indefinitely | Redis/BullMQ not running | Run pnpm tsx infra/scripts/provision-tenant.ts --tenant {slug} manually |
app.embaylms.com/admin/courses/... returns redirect | Correct — tenant routes are blocked on the platform domain | Access at {tenant}.embaylms.com instead |
| ”Platform API only accessible from platform domain” error | /api/platform called from a non-platform domain | Security guard — do not call the platform API from tenant subdomains |
| Impersonation session expired | 1-hour limit reached | Start a new impersonation session from /platform/tenants |