A headless Nostr signer library that gives web apps five authentication paths through one interface. Published as @divinevideo/signer. No UI, no framework lock-in — just a NostrSigner interface your app programs against while users pick how they want to sign.
Note: This package was previously published as
divine-signer. Use@divinevideo/signerfor new installs.
- Five auth methods, one interface. nsec paste, NIP-07 extension, NIP-46 bunker, NIP-46 nostrconnect (QR), and OAuth all implement the same
NostrSigner. - Signing and encryption. Every signer exposes
getPublicKey,signEvent, and both NIP-04 and NIP-44 encrypt/decrypt. - Session persistence. Save a login to any storage with
getItem/setItem/removeItemand restore the signer on the next page load. - Automatic OAuth token refresh.
OAuthSignerrefreshes expired access tokens and hands you the new pair to persist. - Embed bridge. First-party Divine apps running inside a trusted
divine.videoiframe can reuse the host's signer overpostMessageinstead of asking users to sign in twice. - Small and dependency-light. Ships as ESM with
nostr-toolsand@divinevideo/loginas peer dependencies.
| Method | Class | How it works |
|---|---|---|
| nsec paste | NsecSigner |
User pastes a secret key. Signs and encrypts locally via nostr-tools. Simple but security-sensitive. |
| NIP-07 extension | ExtensionSigner |
Delegates to browser extensions (Alby, nos2x, Soapbox Signer). Keys never leave the extension. |
| NIP-46 bunker | BunkerNIP44Signer |
Connects to a remote signer via bunker:// URL over WebSocket relays. |
| NIP-46 nostrconnect | BunkerNIP44Signer |
QR code flow — user scans with a mobile signer app (Amber, Primal, nsec.app). |
| OAuth | OAuthSigner |
OAuth login (e.g. Divine). Signs over HTTP with token refresh and 401/403 handling. |
Every signer implements one interface:
interface NostrSigner {
type: SignerType;
getPublicKey(): Promise<string>;
signEvent(event: EventTemplate): Promise<VerifiedEvent>;
nip04Encrypt(pubkey: string, plaintext: string): Promise<string>;
nip04Decrypt(pubkey: string, ciphertext: string): Promise<string>;
nip44Encrypt(pubkey: string, plaintext: string): Promise<string>;
nip44Decrypt(pubkey: string, ciphertext: string): Promise<string>;
}Your app codes against this interface. The user's choice of auth method is invisible to the rest of your stack. Under the hood each signer wraps a different backend:
NsecSignerholds the secret key and signs/encrypts locally withnostr-tools/pure,nip04, andnip44.ExtensionSignerproxies towindow.nostr(NIP-07) and surfaces clear errors when an extension is absent or lacks NIP-04/NIP-44 support.BunkerNIP44Signerwrapsnostr-tools' NIP-46BunkerSigner, adding connect/reconnect timeouts and a two-phase nostrconnect flow that only completes once the relay subscription is live (avoiding a scan-before-ready race).OAuthSignersigns over HTTP throughDivineRpcfrom@divinevideo/login, verifying every returned event and refreshing tokens on a 401.
Platform fit. Divine's login stack is OAuth-first via @divinevideo/login, and Divine Signer re-exports that client so apps get a single dependency for the full picture: OAuth for Divine accounts, plus the four other Nostr-native paths for users who bring their own keys. The embed bridge lets first-party apps embedded in the Divine host (for example the Flutter web shell) share one signed-in session across iframes.
npm install @divinevideo/signerRequires nostr-tools ^2.23.0 and @divinevideo/login ^1.1.0 as peer dependencies.
import { NsecSigner, ExtensionSigner, BunkerNIP44Signer } from '@divinevideo/signer';
// nsec
const signer = new NsecSigner('nsec1...');
// Browser extension
const signer = new ExtensionSigner();
// Bunker URL
const signer = await BunkerNIP44Signer.fromBunkerUrl('bunker://...');
// Then use it — same API regardless of method
const pubkey = await signer.getPublicKey();
const signed = await signer.signEvent({ kind: 1, content: 'hello', tags: [], created_at: now });
const encrypted = await signer.nip44Encrypt(recipientPubkey, 'secret');For the QR flow, prepare the connection first so the relay subscription is live before you show the code:
import { BunkerNIP44Signer } from '@divinevideo/signer';
import { generateSecretKey } from 'nostr-tools/pure';
const clientKey = generateSecretKey();
const handle = await BunkerNIP44Signer.prepareNostrConnect(nostrconnectUri, clientKey);
// render the QR code now, then:
const signer = await handle.waitForSigner();OAuth uses createDivineClient from @divinevideo/login (re-exported here for convenience):
import { OAuthSigner, createDivineClient } from '@divinevideo/signer';
const clientId = 'my-app';
const serverUrl = 'https://login.divine.video';
const divine = createDivineClient({
serverUrl,
clientId,
redirectUri: `${window.location.origin}/auth/callback`,
storage: localStorage,
});
// Start the flow
const { url } = await divine.oauth.getAuthorizationUrl();
window.location.href = url;
// Handle the callback
const params = new URLSearchParams(window.location.search);
const tokens = await divine.oauth.exchangeCode(params.get('code')!);
if (!tokens.access_token) throw new Error('OAuth response did not include an access token');
const signer = new OAuthSigner(tokens.access_token, {
refreshToken: tokens.refresh_token,
clientId,
apiUrl: serverUrl,
});Save and restore sessions across page reloads:
import { createSessionStore, restoreSession } from '@divinevideo/signer';
// Create a store backed by localStorage (or any storage with getItem/setItem/removeItem)
const sessions = createSessionStore(localStorage, 'my_app');
// After login, save the session
sessions.save({ type: 'oauth', accessToken, refreshToken });
// or: sessions.save({ type: 'extension' });
// or: sessions.save({ type: 'bunker', bunkerUrl: '...' });
// or: sessions.save({ type: 'nostrconnect', clientNsec: '...', bunkerUrl: '...' });
// or: sessions.save({ type: 'nsec', nsec: '...' });
// On page load, restore it
const stored = sessions.load();
if (stored) {
const signer = await restoreSession(stored);
// signer is ready to use
}The OAuthSigner refreshes access tokens automatically when it has a refresh token. Hook into it to persist new tokens:
import { OAuthSigner } from '@divinevideo/signer';
if (signer instanceof OAuthSigner) {
signer.onTokenRefresh = ({ accessToken, refreshToken }) => {
sessions.save({ type: 'oauth', accessToken, refreshToken });
};
}First-party Divine apps embedded in a trusted divine.video host can install a NIP-07 shim that proxies ExtensionSigner calls to the parent frame. Call it once during app startup, before constructing an ExtensionSigner:
import { ExtensionSigner, installDivineEmbedBridge } from '@divinevideo/signer';
installDivineEmbedBridge();
const signer = new ExtensionSigner();
const pubkey = await signer.getPublicKey();
const signed = await signer.signEvent({ kind: 1, content: 'hello', tags: [], created_at: now });
const encrypted = await signer.nip44Encrypt(recipientPubkey, 'secret');The bridge only installs for framed pages whose document.referrer host matches the Divine allowlist. The parent host must answer divine:nostr.request messages for getPublicKey, signEvent, getRelays, nip04.encrypt, nip04.decrypt, nip44.encrypt, and nip44.decrypt.
The library takes no environment variables — configuration is passed to the constructors and factories at call time.
new OAuthSigner(token, options?) accepts:
| Option | Default | Purpose |
|---|---|---|
refreshToken |
undefined |
Enables automatic refresh on 401. Without it, expired tokens throw OAuthError. |
clientId |
'privdm' |
OAuth client id used on refresh requests. |
apiUrl |
https://login.divine.video |
Base URL for the Nostr signing and token endpoints. |
fetchImpl |
globalThis.fetch |
Inject a custom fetch (useful for tests). |
installDivineEmbedBridge(options?) accepts allowedHosts, allowedSuffixes, and requestTimeoutMs. The defaults trust divine.video, app.divine.video, localhost, and any *.divine.video or *.divine-mobile.pages.dev host, with a 60s request timeout. Override the allowlists to embed under a different trusted origin.
NsecSigner(nsec: string)— local signing from a secret keyExtensionSigner()— delegates towindow.nostr(NIP-07)BunkerNIP44Signer.fromBunkerUrl(input, params?, overrideType?, connectTimeout?)— connect via bunker URL or NIP-05 identifierBunkerNIP44Signer.reconnect(clientSecretKey, bunkerUrl, params?, connectTimeout?)— restore a bunker session without re-sendingconnectBunkerNIP44Signer.prepareNostrConnect(uri, clientSecretKey, params?, timeoutOrAbort?)— two-phase QR flow; returns aNostrConnectHandleBunkerNIP44Signer.fromNostrConnect(uri, clientSecretKey, params?, timeoutOrAbort?)— one-call QR flow- Instance helpers:
getBunkerUrl()returns thebunker://URL;close()tears down relay connections OAuthSigner(token, options?)— HTTP signing via OAuth APIOAuthError— thrown on 401/403 (checkerror.status)
DivineOAuth— OAuth flow manager (PKCE, authorize URL, code exchange)createDivineClient(config)— factory for the Divine RPC clientgeneratePkce()— generate PKCE code verifier + challenge
createSessionStore(storage, prefix)— returns{ save, load, clear }restoreSession(stored)— reconstructs aNostrSignerfrom aStoredSession
installDivineEmbedBridge(options?)— installs the framed-appwindow.nostrbridge when the parent origin is trusted; returnstruewhen installedisDivineEmbedded()— whether the bridge installed in the current windowgetDivineParentOrigin()— the trusted parent origin after install, otherwisenullDEFAULT_ALLOWED_PARENT_HOSTS/DEFAULT_ALLOWED_PARENT_SUFFIXES— default parent host allowlists
NostrSigner— the signer interface all methods implementSignerType—'nsec' | 'extension' | 'bunker' | 'nostrconnect' | 'oauth'NostrConnectHandle—{ waitForSigner, abort }returned byprepareNostrConnectStoredSession/SessionStore/SessionStorage— session persistence shapesTokenRefreshResult—{ accessToken, refreshToken }EmbedBridgeOptions/EmbedBridgeRequest/EmbedBridgeResponse— embed bridge message contractDivineClientConfig,DivineStorage,OAuthResult,PkceChallenge,TokenResponse,StoredCredentials— OAuth types
npm install # install dependencies
npm run build # bundle the library (esbuild) and emit type declarations (tsc)
npm run typecheck # type-check without emitting
npm test # run the Vitest suite
npm run test:watch # run tests in watch modeThe build bundles src/index.ts to ESM with nostr-tools and @divinevideo/login left external. npm publish runs the build automatically via prepublishOnly. If you change the public API or example flows, update the README and the example together.
examples/vanilla/— minimal single-page app wiring all five auth methods, no framework, no CSS, just the API.- privdm — a full React app (NIP-17 encrypted DMs) using this signer in production.
Part of Divine — your playground for human creativity · Brand guidelines