@providame/sdk-js
v0.0.15
Published
Providame auth SDK — vanilla JS, zero runtime deps, drop-in components and headless auth primitives (for plain <script> tag / non-TypeScript use; see @providame/core in sdk/core for the TypeScript SDK sdk/vue builds on)
Maintainers
Readme
@providame/sdk-js
Vanilla JS auth SDK for Providame — zero runtime dependencies, ships ESM, CJS,
and UMD, and works from a plain <script> tag.
On Vue or React? Use
@providame/vueor@providame/react. On another framework, or want TypeScript types? Use@providame/core. On a server? Use@providame/backend.Building a native or hybrid app (React Native, Expo, Capacitor, Ionic)? This package is browser-only — use
@providame/corewith a native key (nk_) instead. See the Native apps guide.
Install
npm install @providame/sdk-jsUsage
Drop-in components
<div id="sign-in"></div>
<script type="module">
import { ProvidameClient, SignIn } from '@providame/sdk-js'
const providame = new ProvidameClient({ publishableKey: 'pk_test_...' })
SignIn.mount({
client: providame,
target: '#sign-in',
signUpUrl: '/sign-up',
onSuccess: () => window.location.href = '/app',
})
</script>Component styles are injected automatically on mount — no CSS import is needed.
(@providame/vue is the exception: it ships a real stylesheet you must import.)
Modal vs. page usage
The footer link behaves differently depending on how you mount these:
- Page — you mount
SignIninto your own/sign-inpage. PasssignUpUrland the footer renders a normal link that navigates. - Modal —
client.openSignIn()handles this for you: the footer switches to sign-up in place, because navigating would tear the modal down.openSignUp()opens the same modal on the sign-up view.
providame.openSignIn() // footer switches to sign-up in place
providame.openSignUp() // same modal, opens on sign-up
providame.closeSignIn() // closes it — closeSignUp() is equivalentMounting into your own modal? Pass the switch callback instead of a URL and swap the views yourself:
SignIn.mount({
client: providame,
target: content,
onSwitchToSignUp: () => {
SignIn.unmount(content)
SignUp.mount({ client: providame, target: content, onSwitchToSignIn: /* … */ })
},
})Unmount the outgoing view before mounting the next one — that is what releases its auth-state listener. The callback takes precedence when both it and a URL are supplied.
Headless
import { ProvidameClient } from '@providame/sdk-js'
const providame = new ProvidameClient({ publishableKey: 'pk_test_...' })
// Sign up
await providame.signUp({ email, password, fullName })
// Sign in
await providame.signIn({ email, password })
// Sign out
await providame.signOut()
// Check session
providame.isSignedIn() // → boolean
// Listen for state changes
providame.onAuthStateChange((event, data) => {
// event: 'signed-in' | 'signed-out'
})signUp() only completes a session when the backend says so. Email verification
is always required, so the usual sequence is signUp() then
verifySignUp({ flowId, code, password }) — all three fields are required, and
omitting any of them throws. Note the case flip: signUp() resolves to the raw
response { flow_id, status }, while verifySignUp takes flowId.
const { flow_id } = await providame.signUp({ email, password })
await providame.verifySignUp({ flowId: flow_id, code, password })The prebuilt SignUp component already handles that two-step flow for you.
Other sign-in strategies
import {
signInWithOTPEmail, verifyOTPEmailSignIn,
signInWithOTPSMS, verifyOTPSMSSignIn,
signInWithMagicLink, signInWithPasskey,
resendOTPEmail, resendOTPSMS, resendMagicLink,
} from '@providame/sdk-js'Multi-factor sign-in
signIn() can return needs_second_factor (an already-enrolled account) or
needs_mfa_enrollment (a forced-MFA environment, no second factor yet)
instead of completing. The prebuilt <SignIn> already renders both follow-up
steps; these are the headless primitives underneath it:
const res = await providame.signIn({ email, password })
if (res.status === 'needs_second_factor') {
await providame.attemptSignInSecondFactor({
flowId: res.flow_id,
strategy: 'totp', // or 'backup_code'
code,
})
}
if (res.status === 'needs_mfa_enrollment') {
const { secret, otpauth_uri } = await providame.enrollSignInTotp({ flowId: res.flow_id })
// ...render otpauth_uri as a QR code, or show `secret` for manual entry...
await providame.verifySignInTotpEnrollment({ flowId: res.flow_id, code })
// A 'complete' response's session carries recovery_codes — display them
// once; they're never retrievable again.
}Password reset
<div id="reset-password"></div>
<script type="module">
import { ProvidameClient, ResetPassword } from '@providame/sdk-js'
const providame = new ProvidameClient({ publishableKey: 'pk_test_...' })
ResetPassword.mount({
client: providame,
target: '#reset-password',
onSuccess: () => window.location.href = '/app',
signInUrl: '/login',
})
</script>Headless, or as a modal via providame.openResetPassword({ onSuccess, signInUrl }) /
providame.closeResetPassword():
// Always resolves — no account enumeration — a code is only emailed if the
// address exists.
const { flow_id } = await providame.resetPassword({ email })
// The password IS reset by this call regardless of status — only the
// automatic sign-in that follows is gated for an MFA-enrolled account.
const res = await providame.attemptResetPassword({ flowId: flow_id, code, password })
if (res.status === 'needs_second_factor') {
await providame.attemptResetPasswordSecondFactor({ flowId: flow_id, strategy: 'totp', code: totpCode })
}
if (res.status === 'needs_mfa_enrollment') {
const { secret, otpauth_uri } = await providame.enrollResetPasswordTotp({ flowId: flow_id })
await providame.verifyResetPasswordTotpEnrollment({ flowId: flow_id, code: totpCode })
}
await providame.resendResetPassword({ flowId: flow_id })Social login
import { messageForIdpErrorCode } from '@providame/sdk-js'
const idps = await providame.listEnabledIdps()
// -> [{ provider: 'google', displayName: 'Google' }, ...] — never throws,
// resolves to [] on a network/CORS failure so you just render no buttons
await providame.signInWithIdp('google', { returnUrl: '/app' }) // navigates away
// ProvidameClient strips a providame_error query param on return and
// replays it as a 'social-login-error' event to the first subscriber.
providame.onAuthStateChange((event, data) => {
if (event === 'social-login-error') alert(messageForIdpErrorCode(data.socialLoginError))
})Session, tokens, and environment info
// Your own backend's Authorization header — cached, decoded from the
// token's own exp claim; a real round trip only happens once it's actually
// expired, or you pass { skipCache: true }.
const token = await providame.getToken()
const config = await providame.configuration() // issuer, JWKS URI, Turnstile site key
const branding = await providame.branding() // this environment's colors/logo — never rejects
const env = await providame.getEnvironment() // OIDC client ID, kind (dev/staging/prod), statusProvidameClient already constructs and drives a SessionManager internally
(its self-rescheduling silent-refresh loop) — you don't need your own for
ordinary use. It's exported for a custom framework integration that needs to
control that loop directly: new SessionManager(providame), then
.start() (session already known live), .resume() (page-load case — asks
the server via providame.me(), since there's no synchronous way to tell),
.stop().
Account management
await providame.user.update({ givenName, familyName })
await providame.user.changePassword({ currentPassword, newPassword })
await providame.user.changeEmail('[email protected]')
await providame.user.verifyEmailChange(code)
await providame.user.setPhone('+15551234567')
const { secret, otpauthUri } = await providame.user.totp.enroll()
await providame.user.totp.verify(code)
if (await providame.user.totp.isEnrolled()) await providame.user.totp.remove()
const passkeys = await providame.user.passkeys.list()
const { passkeyId, creationOptions } = await providame.user.passkeys.register(domain)
await providame.user.passkeys.verify(passkeyId, credential, name)
await providame.user.passkeys.remove(passkeyId)
await providame.user.passkeys.registerWithBrowser(domain, name) // register + browser prompt + verify, one call
// Admin-issued passkey registration link (user is NOT signed in — the
// emailed code is the credential):
await providame.user.passkeys.registerWithCode(identifier, codeId, code, domain)
await providame.user.passkeys.verifyWithCode(passkeyId, identifier, credential, name)
const sessions = await providame.user.sessions.list()
await providame.user.sessions.revoke(sessionId)
// Auth-method + step-up state
const { hasTotp, hasPassword } = await providame.user.mfaState()
await providame.user.hasPassword()
// Step-up re-auth, required by deleteAccount() and any endpoint the backend
// guards with X-Step-Up-Token. Takes { password } or { code } (from
// requestReauthCode(), the only step-up path for an account with no
// password at all — social-login sign-up).
const { stepUpToken, expiresAt } = await providame.user.reauth(currentPassword)
await providame.user.requestReauthCode()
await providame.user.deleteAccount(currentPassword) // takes the same step-up proof, internally re-auths
const { avatarUrl } = await providame.user.uploadAvatar(file)
await providame.user.removeAvatar()
const data = await providame.user.exportData() // GDPR export — everything FAPI
// already shows this end-user,
// never private_metadata
// Claims (display/gating only — never verified client-side)
const claims = await providame.getClaims()mountUserButton and mountUserProfile render full drop-in UI over the same
API (each with a matching unmount*), or as modals:
providame.openUserProfile()
providame.closeUserProfile()Organizations
Providame's B2B tenancy layer: your customers are companies with members and
roles. The API matches @providame/core method for method.
import { mountOrganizationSwitcher } from '@providame/sdk-js'
const orgs = await providame.organizations.list()
const org = await providame.organizations.get(orgs[0].id)
await providame.organizations.setActive(orgs[0].id) // null clears it
await providame.organizations.setActiveBySlug('acme')
// setActive is remembered across sign-ins -- you don't call it on every login
// CRUD
const created = await providame.organizations.create({ name: 'Acme Inc', slug: 'acme' })
await providame.organizations.update(created.id, { name: 'Acme Corp' })
await providame.organizations.delete(created.id)
// Members — setMemberRoles REPLACES across both tiers; [] revokes all
await providame.organizations.members(org.id)
await providame.organizations.setMemberRoles(org.id, userId, [{ key: 'org:admin', scope: 'environment' }])
await providame.organizations.removeMember(org.id, userId) // a member may always remove themselves
// Org-local roles (permissions come from the ENVIRONMENT's catalog)
await providame.organizations.roles(org.id)
await providame.organizations.createRole(org.id, { roleKey: 'billing-owner', displayName: 'Billing Owner', permissionKeys: ['billing:manage'] })
await providame.organizations.updateRole(org.id, 'billing-owner', { displayName: 'Billing Lead' }) // permissionKeys omitted leaves it unchanged
await providame.organizations.deleteRole(org.id, 'billing-owner')
// Invitations — the token itself is never returned here, only emailed
await providame.organizations.invitations(org.id)
await providame.organizations.invite(orgId, { email: '[email protected]' })
await providame.organizations.revokeInvitation(org.id, invitationId)
await providame.organizations.myInvitations()
await providame.organizations.acceptInvitation(token)
// Verified domains + domain-based suggestions
await providame.organizations.domains(org.id)
await providame.organizations.addDomain(org.id, { domain: 'acme.com' })
await providame.organizations.setDomainEnrollmentMode(org.id, domainId, 'automatic_invitation')
await providame.organizations.verifyDomain(org.id, domainId)
await providame.organizations.removeDomain(org.id, domainId)
await providame.organizations.suggestions()
await providame.organizations.joinSuggested(suggestedOrgId)
mountOrganizationSwitcher({
client: providame,
target: '#org-switcher',
onChange: (org) => console.log('switched to', org),
hideCreate: false, // hide the "Create organization" action
hidePersonal: false, // hide the "no active organization" option
personalLabel: 'Personal account',
})Only the switcher is prebuilt here — there's no vanilla equivalent of
<OrganizationProfile> or <CreateOrganization> yet. Build those over the API
above.
Roles and permissions
import {
hasRole, hasPermission, hasOrgRole, hasOrgPermission, activeOrganization,
} from '@providame/sdk-js'
await hasRole(providame, 'admin') // environment-level grant
await hasOrgPermission(providame, 'billing:manage') // ACTIVE organization only
await activeOrganization(providame) // { id, slug, name } | nullEach has an ...InClaims variant (hasRoleInClaims, activeOrganizationFromClaims,
…) taking decoded claims directly, for when you already hold them and don't want
the client to fetch. This is display gating only — real enforcement is your
backend verifying the token, via @providame/backend.
Object-scoped access
hasRole/hasPermission read claims already in the token — free, and answer
"is this user an admin". providame.authz.can() asks about one specific
resource — a real network call, for "can this user edit this document":
const allowed = await providame.authz.can({ permission: 'edit', resource: 'document:42' })
if (allowed) {
// show the Edit button
}
const [access] = await providame.authz.listResources({ permission: 'edit', resourceType: 'document' })
// access.scopes is a UNION of shapes: [{ kind: 'all' }] means every document;
// [{ kind: 'instances', instances: [...] }] is the complete, enumerable list, e.g. ['document:42', 'document:107']
const editableDocumentIds = access.scopes.some((s) => s.kind === 'all')
? allDocumentIds
: access.scopes.flatMap((s) => s.instances ?? [])No prebuilt <Can>-equivalent component here — call can() directly and
render accordingly. Fails closed: a network failure rejects rather than
resolving true, so check for that in your own error handling. Answers are
cached per-question for the life of the session (cleared on sign-in/out and
organization switch) via providame.authz.invalidate().
App audit events
sendAuditEvents(events) posts up to 100 of your app's own audit events for
the signed-in user. Every event comes back in exactly one of accepted or
rejected — both are final, so delete it locally once it appears in either.
An event the same user resends is not stored twice; anything past the first
100 is in neither list, so send it again. Each user may send 30 batches a
minute; past that the call throws an error with status 429 and nothing is
stored — keep the events and retry after err.retryAfterSeconds.
const { accepted, rejected, errors } = await providame.sendAuditEvents([
{ eventId: crypto.randomUUID(), action: 'record.viewed', timestamp: new Date().toISOString(), resourceId: 'rec_42' },
])Utilities
import { hasSessionCookie, isWebAuthnSupported, renderTurnstile } from '@providame/sdk-js'
hasSessionCookie() // always false in a real browser — providame_eu_session is
// HttpOnly, so document.cookie can never see it. Kept for
// backwards compatibility; use isSignedIn()/me() instead.
if (isWebAuthnSupported()) {
// show "Sign in with a passkey" — gate on real browser support first
}
// Renders a Cloudflare Turnstile widget; onToken fires with the challenge
// token once the user passes it (often invisibly). Returns a cleanup
// function — call it on unmount so a re-mounted form gets a fresh widget.
const cleanup = await renderTurnstile(containerEl, siteKey, (token) => {
// include token in your own signup/signin request body
})
cleanup()Docs
Full reference: /docs/sdk/vanilla in your Providame dashboard.
Build
npm run build