TryMellon
Navigation

Hosted Onboarding

Onboard your platform's own tenants via @trymellon/js/platform. The passkey ceremony runs under trymellon.com so RP ID is bound correctly, and the user returns to your app on completion.

Hosted Onboarding

Build a platform that hosts other people’s apps? Hosted onboarding lets those platform operators (your customers’ admins) sign themselves up onto TryMellon without you embedding the WebAuthn ceremony in your own origin.

TryMellon spins up a hosted signup page at https://trymellon.com/signup/<sessionId>?return=<yourAppCallback>. The ceremony runs there (so the passkey RP ID is trymellon.com), and on completion the user is redirected back to your app. Exactly the pattern Stripe Connect, Auth0 Universal Login and Clerk use.

When to use this

ScenarioSurface
End-user login in your appclient.signUp() / client.signIn() on @trymellon/js
A platform admin (your customer) needs a TryMellon accountcreatePlatform().createSignupLink() on @trymellon/js/platform ← this guide
Cross-device login with a QRclient.crossDevice.* (see Cross-device QR)

Why a separate sub-path? @trymellon/js/platform has a different trust model — it issues signup links with no publishable key. We keep it off the main client so your end-user bundle doesn’t pay the cost, and your TypeScript autocomplete doesn’t leak a surface the end-user flow can’t use (enforced by client.platform: never — see ADR-SDK-005).


Install

npm install @trymellon/js

No new package — the sub-path ships in the same npm release.

Flujo típico (polling, recomendado):

import { createPlatform } from '@trymellon/js/platform'

const platform = createPlatform({
  apiBaseUrl: 'https://api.trymellonauth.com', // optional; this is the default
})

// SIN returnUrl. La hosted page redirige al dashboard de TryMellon al terminar;
// tu app se entera por `awaitSignupCompletion()` (paso 3).
const link = await platform.createSignupLink({
  userRole: 'maintainer',
  prefill: { companyName: 'ACME', email: 'founder@acme.com' }, // UX only, never trust
})

if (!link.ok) throw link.error
console.log(link.value.hostedUrl)
// → https://trymellon.com/signup/<sessionId>

Avanzado — returnUrl a tu propia app (requiere allowlist registrado):

const link = await platform.createSignupLink({
  returnUrl: 'https://acme.com/onboarded', // pre-registrado en el allowlist del account
  userRole: 'maintainer',
  refreshUrl: 'https://acme.com/signup-expired', // optional fallback
})

Sin allowlist pre-registrado, pasar un returnUrl a tu propio origen falla FORBIDDEN / invalid_return_url. Para first-signup el flujo de arriba (polling) es la respuesta correcta — el allowlist per-account se configura una vez que el account existe.

2. Hand the user the URL

You choose the delivery channel. Full-page redirect, popup, or a QR code for mobile → desktop handoff.

// Full-page redirect (desktop)
window.location.href = link.value.hostedUrl

// QR code (bring your own lib — we stay zero-dep)
import QRCode from 'qrcode'
const dataUrl = await QRCode.toDataURL(link.value.hostedUrl)

3. Wait for completion (optional)

If you want to react server-side the moment the ceremony finishes (e.g. provision resources in your app), poll the terminal state:

const controller = new AbortController()
setTimeout(() => controller.abort(), 5 * 60 * 1000) // 5 min max

const completion = await platform.awaitSignupCompletion(link.value.sessionId, {
  signal: controller.signal,
  intervalMs: 2_000, // default
})

if (!completion.ok) {
  // 'ABORT_ERROR' | 'TIMEOUT' | 'SESSION_EXPIRED' | 'SERVER_ERROR'
  return handleError(completion.error)
}
// The hosted page already redirected the user to returnUrl.
// Your backend can now provision tenant-side state.

If you only need a one-shot status check:

const status = await platform.getSignupStatus(link.value.sessionId)
// { status: 'pending_data' | 'pending_passkey' | 'completed' | 'expired' | 'failed', hostedUrl? }

Return URL allowlist

  • returnUrl must be https.
  • For first-signup (no TryMellon account yet), returnUrl is validated against a hardcoded path enum on the TryMellon landing origin. This is an anti-open-redirect safety net.
  • Once your account exists, the allowlist of accepted return_url / refresh_url values is stored per-account and editable from the dashboard. URLs are compared with string-exact matching — no path wildcards, no substring matches.
  • Invalid URLs fail fast with TryMellonError.code === 'INVALID_ARGUMENT' before any HTTP call.

See ADR-076 §2.2 for the security rationale.

Error codes

codeWhen
INVALID_ARGUMENTreturnUrl is not a valid https URL.
NETWORK_FAILUREUnreachable API.
FORBIDDENreturnUrl passed validation client-side but the server allowlist rejected it.
RATE_LIMIT_EXCEEDED2 signup links per hour per IP (AI agent safeguard).
SESSION_EXPIREDSession expired before the ceremony completed.
ABORT_ERRORAbortSignal fired during awaitSignupCompletion.
TIMEOUTmaxAttempts exhausted without reaching a terminal state.

Bundle size

@trymellon/js/platform is a separate tree-shaken bundle — ~2.92 KB gzip, no dependencies. If you only use it server-side (e.g. in a Next.js API route), it will never ship to the browser.