npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nostr-mill

v1.8.2

Published

MILL — Multi-Interface Login Layer. Lightweight Web Component for Nostr account access. Supports NIP-07, NIP-46, NIP-55, private key, read-only, new keypair, and optional Continue-with-Google (pomegranate/FROST or Drive+PIN).

Readme

MILL — Multi-Interface Login Layer

Lightweight, drop-in Nostr signer UI as a Web Component.
One <script> tag, every Nostr signing method — plus an optional "Continue with Google" onboarding path for non-technical users, who can take full control of their key whenever they choose.

Core signing methods carry no runtime dependencies of note. The opt-in Google paths pull in crypto libraries (@noble/*, @scure/bip39, and — for pomegranate — @fiatjaf/promenade-trusted-dealer); these ship in the bundle but only run when a user actually uses those paths.

npm license


Supported Methods

| Method | NIP | Description | |---|---|---| | Browser Extension | NIP-07 | Alby, nos2x, Flamingo, Nostore | | Remote Signer | NIP-46 | Bunker URL or QR scan | | Android Signer | NIP-55 | Amber — clipboard return by default, no server needed | | Private Key | — | nsec/hex, AES-256 encrypted in sessionStorage | | Read Only | — | Public key / npub view-only access | | New Identity | — | Generate keypair in-browser | | Google — Pomegranate † | — | "Continue with Google", cross-client: FROST-sharded key, never stored whole. Client of fiatjaf's pomegranate. | | Google — Drive+PIN † | — | "Continue with Google", per-app: encrypted key in the user's own Drive, unlocked by a PIN. Import/export anytime. |

† Both are opt-in and off by default — each appears only when configured (pomegranate for the FROST path, oauthShim for Drive+PIN); pomegranate takes precedence if both are set. Existing hosts see no change to the picker until they opt in. See Continue with Google.

For private-key signing, MILL also acts as the signer and shows a per-event consent card (approve/reject with a remember-my-choice duration) — see Signing consent.


Public API (SemVer surface)

These are the only symbols and shapes covered by SemVer. Anything else in src/ or dist/ is internal and may change in a patch release.

  • MILL.open(options) — options: theme, methods, moreMethods, moreLabel, methodOverrides, platforms, platform, onConnected, onClose, amberCallback, appName, oauthShim, pomegranate, header, footer, tip
  • MILL.restore({ method, pubkey })
  • MILL.openSettings() — per-kind signing permissions (private-key signing only)
  • MILL.installAsWindowNostr(signer)
  • deliverAmberCallback({ autoClose })
  • <nostr-signer> attributes: theme, amber-callback, app-name, oauth-shim
  • Events: mill:connected, mill:disconnected
  • The MillResult object (see "Return value" below)
  • The CSS variables listed under "Theming"
  • Named exports from nostr-mill/themes: brandTheme, applyTheme

Install

CDN (zero config)

<!-- Self-hosted -->
<script src="https://cdn.oslim.dev/mill/mill.umd.js"></script>

<!-- Or via jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/nostr-mill/dist/mill.umd.js"></script>

npm

npm install nostr-mill
# nostr-tools is an optional peer dep for real key derivation:
npm install nostr-tools

Usage

Script tag / CDN

<script src="mill.umd.js"></script>

<button onclick="MILL.open({ onConnected: console.log })">
  Connect Nostr Account
</button>

Web Component

<nostr-signer id="signer" theme="dark"></nostr-signer>

<script>
  const signer = document.getElementById('signer');

  // Open programmatically
  signer.open({
    onConnected: (result) => {
      console.log(result.method);   // 'nip07' | 'nip46' | 'nip55' | 'privatekey' | 'readonly' | 'newkey' | 'google' | 'pomegranate'
      console.log(result.pubkey);   // hex pubkey
    }
  });

  // Or listen via events
  signer.addEventListener('mill:connected', (e) => {
    const { method, pubkey } = e.detail;
  });

  signer.addEventListener('mill:disconnected', () => {
    console.log('user disconnected');
  });
</script>

ESM / bundler

import MILL from 'nostr-mill';

MILL.open({
  theme: 'dark',
  onConnected: (result) => {
    // result.method  — which method the user chose
    // result.pubkey  — hex public key
    // result.signer  — window.nostr-compatible interface (where available)
  },
  onClose: () => console.log('modal closed'),
});

Choosing which methods show

methods is the main sign-in list, in the order you give (each entry a method id or an override object like { id, label, icon }). moreMethods takes the same entries but tucks them into a collapsed "More options" disclosure below the main list, so you can enable a method without cluttering the primary choices. moreLabel renames that disclosure (default "More options").

MILL.open({
  methods:     ['pomegranate', 'nip07'],              // main section, in this order
  moreMethods: ['nip46', 'privatekey', 'readonly'],   // collapsed under "More options"
  moreLabel:   'Advanced sign-in',                    // optional label
});

Method ids: nip07, nip46, nip55, privatekey, readonly, newkey, pomegranate, google. A method listed in moreMethods is pulled out of the main list, so it never appears twice — even with the default methods you can push, say, readonly into the dropdown by naming it in moreMethods alone. Omit methods to keep the default main set; omit moreMethods for a single flat list.

Customising a method's label, badge, and color

Each card's text and badge are editable. methodOverrides is a per-id map applied to a method wherever it appears (main, More, and every platform), so you set it once. Fields: label, sub, desc, icon, secLabel (the "Easiest" / "Recommended" pill text), secColor (its color — any CSS color: hex, rgb(), named, or var(--mill-…)).

MILL.open({
  methodOverrides: {
    nip07:       { secLabel: 'Top pick', secColor: '#ff4488' },
    pomegranate: { label: 'Sign in with Google', sub: 'no keys to manage', secLabel: 'Easiest' },
    readonly:    { secLabel: '' },          // '' hides the badge
  },
});

(You can also override inline per entry — methods: [{ id: 'nip07', secLabel: 'Best' }] — but methodOverrides stays DRY across platforms.) examples/playground.html has an inline editor (the ✎ on each method) for label / sub / badge text / badge color.

Platform-specific layouts

platforms is a per-platform override map, keyed by desktop, android, ios, or mobile (matches android or ios). mill detects the platform and shallow-merges the matching block over the base options, so you write one base config plus the overrides that differ. Any option can be overridden; method placement is the common one, e.g. put Amber (NIP-55) in the main list on Android and push the browser extension into "More options":

MILL.open({
  methods:     ['newkey', 'pomegranate', 'nip07'],   // desktop / base
  moreMethods: ['nip46', 'privatekey', 'readonly', 'nip55'],
  platforms: {
    android: { methods: ['newkey', 'pomegranate', 'nip55'],   // Amber up front
               moreMethods: ['nip46', 'nip07', 'privatekey', 'readonly'] },
  },
});

Detection is navigator.userAgent based (iPadOS is treated as ios). Pass platform: 'android' | 'ios' | 'desktop' to force one — useful for testing, and what examples/playground.html uses to preview each platform's layout.


Header & footer branding

Brand the modal with your own header and footer.

MILL.open({
  header: {
    logo: 'https://yourapp.com/logo.png',   // image URL (PNG/SVG/…) at natural size, or an emoji/short text
    logoHeight: 48,                          // px height for image logos (default 44)
    eyebrow: 'YOURAPP',                      // small uppercase line by the logo (default "Nostr Signer")
    title: 'Sign in',                        // main heading (default "Connect Your Account")
    message: 'Your keys, your Nostr.',       // description (default "Choose how to access …")
    align: 'center',                         // 'left' (default) | 'center'
    gap: '8px',                              // spacing between header lines (default 6px)
    marginBottom: '24px',                    // space below the header (default 22px)
    label: 'Secure Login',                   // the top strip eyebrow (default "Account Access"); '' hides it
  },
  tip: false,                                 // hide the "Not sure? …" line under the methods (or pass a string)
  footer: {
    text: 'Your identity · Your data · Your money',
    links: [
      { label: 'Terms',   href: 'https://yourapp.com/terms' },
      { label: 'Privacy', href: 'https://yourapp.com/privacy' },
    ],
    attribution: true,                        // "Signer by MILL" link — ON by default
    attributionHref: 'https://…',             // optional: override where it points
  },
});

Every field is independent — set one and the rest keep their defaults. Pass '' (or false) for any of logo / eyebrow / title / message to hide just that line (e.g. { eyebrow: '', message: '' } for logo + title only). Leave header unset for mill's default block. A broken image URL is dropped silently (no broken-image icon). label: '' hides the top strip label (the close button stays); tip: false hides the recommendation line, or pass a string to replace it. align: 'center' centers the header; every corner follows --mill-radius.

Footer (Terms / Privacy / attribution)

The method picker can show a configurable footer — your own tagline and links (Terms, Privacy, …), plus a small "Signer by MILL" attribution.

MILL.open({
  footer: {
    text: 'Your identity · Your data · Your money',   // optional left tagline
    links: [                                          // optional links (open in a new tab)
      { label: 'Terms',   href: 'https://yourapp.com/terms' },
      { label: 'Privacy', href: 'https://yourapp.com/privacy' },
    ],
    attribution: true,                    // "Signer by MILL" link — ON by default
    attributionHref: 'https://…',         // optional: override where it points
  },
});
  • The attribution is on by default; set attribution: false to hide it.
  • Omit footer entirely and you still get just the attribution. Pass { attribution: false } with no links/text for no footer at all.
  • Links open with target="_blank" rel="noopener noreferrer".

Theming

MILL uses CSS custom properties scoped to the Shadow DOM :host. Override them externally:

nostr-signer {
  --mill-accent:   #00c896;
  --mill-bg:       #0a0a0a;
  --mill-radius:   8px;
  --mill-font:     'Your App Font', sans-serif;
}

Built-in themes

// Named themes: 'dark' (default), 'light', 'minimal', 'grain', 'native'
// 'native' is deliberately unstyled — system font, square corners, no shadows,
// glows, or backdrop blur — for a plain browser-HTML look.
MILL.open({ theme: 'native' });

// Or pass a partial token object — merged onto the dark baseline
MILL.open({
  theme: {
    '--mill-accent':     '#ff6b35',
    '--mill-bg':         '#0f0f0f',
    '--mill-radius':     '4px',
    '--mill-font':       "'IBM Plex Sans', sans-serif",
  }
});

// Or use brandTheme() helper — pass just a few inputs
import { brandTheme } from 'nostr-mill/themes';
MILL.open({ theme: brandTheme({ accent: '#7c3aed', radius: '6px' }) });

Full CSS variable reference

| Variable | Default | Description | |---|---|---| | --mill-bg | #09080f | Modal backdrop background | | --mill-surface | #100e1b | Modal surface | | --mill-card | #181528 | Method card background | | --mill-card-hover | #1f1c35 | Method card hover | | --mill-border | #2a2544 | Default border | | --mill-border-light | #3e3860 | Highlighted border | | --mill-accent | oklch(0.67 0.28 282) | Primary accent (purple) | | --mill-accent-dim | …/ 0.13 | Accent tint background | | --mill-teal | oklch(0.67 0.18 195) | Secondary accent | | --mill-text | #ede8fc | Primary text | | --mill-text-secondary | #9d94c0 | Secondary text | | --mill-muted | #5e5880 | Muted / placeholder text | | --mill-danger | oklch(0.65 0.24 15) | Error / danger states | | --mill-warning | oklch(0.78 0.18 65) | Caution states | | --mill-success | oklch(0.7 0.2 155) | Success / positive states | | --mill-radius | 14px | Corner radius for all elements (set 0 for square) | | --mill-shadow | 0 24px 64px … | Modal drop shadow (none to remove) | | --mill-glow | var(--mill-accent) | Accent glow color (transparent to remove) | | --mill-overlay-blur | 5px | Backdrop blur behind the modal (0 to remove) | | --mill-font | 'Space Grotesk', system-ui | UI font stack | | --mill-font-mono | 'JetBrains Mono', monospace | Monospace font stack |


Events

| Event | e.detail | Description | |---|---|---| | mill:connected | { method, pubkey, signer?, perms? } | User successfully connected | | mill:disconnected | {} | User disconnected |


Return value (result object)

type MillResult = {
  method:    'nip07' | 'nip46' | 'nip55' | 'privatekey' | 'readonly' | 'newkey' | 'google' | 'pomegranate';
  pubkey:    string;          // hex-encoded public key, always present
  perms?:    SigningPerms;    // per-category pre-approval (privatekey / newkey / google)
  bunkerUrl?: string;         // NIP-46 only
  nsec?:     string;          // newkey flow only — the generated nsec (handle carefully)
};

// { notes | profile | contacts | dms | zaps | other → 'session' | 'prompt' }
//   'session' — auto-approve this category until the tab closes
//   'prompt'  — show the consent card and let the user decide
type SigningPerms = Record<string, 'session' | 'prompt'>;

Continue with Google (Pomegranate / FROST) — experimental, cross-client

The cross-client Google path: a user signs in with Google in any implementing client and gets the same Nostr identity. This is a client of fiatjaf's pomegranate — the key is FROST-sharded across independent operator servers and never stored whole (no app, including mill, ever holds it); Google only authenticates the user to the operators; signing runs over NIP-46 through a central coordinator. To any client it is a normal NIP-46 bunker.

// Simplest — join the shared njump ecosystem (recommended for interop):
MILL.open({ pomegranate: true });

// Or self-host / customise:
MILL.open({
  pomegranate: {
    central:   'https://central.yourdomain.com',        // default central (Advanced select)
    operators: ['https://op1…', 'https://op2…', 'https://op3…'],
    threshold: 2,                                         // kept while ≤ n−1, else formula ~7/12 of n
    relays:    ['wss://relay.damus.io', /* … */],         // discovery relays (optional)
    pinCentral: false,                                    // default true; false restores discovery + interstitial
    centralChoices:  ['https://central.example.com'],     // extra centrals in the Advanced select
    operatorChoices: ['https://po.oslim.dev'],            // extra operators, listed unchecked
    allowCustomCentral: true,                             // false hides the central row (white-label)
    allowCustomOperators: true,                           // false hides the operator rows
    minOperators: 3,                                      // never create an account with fewer than this
  },
});
  • Opt-in, off unless you pass pomegranate. pomegranate: true (or {}) uses the njump ecosystem defaults — central auth.njump.me, operators po.f7z.io, po.coracle.social, po.njump.me, po.jumble.social (3-of-4), the same set Jumble and fiatjaf's admin client use — so a user gets the same key here as in any other njump-based app. When set it takes precedence over the Drive+PIN path so there's never a double "Continue with Google".
  • To run your own central + operator servers instead, pass them explicitly — see the handoff/deploy guide and Choosing a central below. The central is the Google OAuth handler, so mill needs no shim for this path.
  • At signup the user can generate a fresh key or bring their own (import an existing nsec/hex) to shard — so an established identity can move onto pomegranate, not just a brand-new one. Either way the nsec is shown once for optional backup. Importing warns that the operators become semi-custodians of that identity (a threshold of them could rebuild it).
  • Returning users are discovered by Google account across clients; "Recover my key from operators" reconstructs the key from a threshold of shards.
  • The key behind a Google account can be replaced in-app ("Use a different key with this Google account"): back up the old key, erase its shards at each operator (one Google popup each), then shard a fresh or imported key. Still one identity per account at a time — see the note below.

One key per Google account — but replaceable. Pomegranate's central keys each account by email, so a given Google account maps to exactly one Nostr identity at a time. It can be swapped from inside mill: the idle screen's "Use a different key with this Google account" link erases the old shards (one Google confirmation popup per operator) and re-shards a fresh or imported key in its place — after prompting the user to back up the old key first, since the swap is irreversible. Pomegranate "profiles" are multiple NIP-46 bunkers for the same npub (permission scopes), not separate identities. For several distinct identities in parallel, use bring-your-own-key with separate Google accounts; multiple distinct npubs under one Google account would require forking central (breaking cross-client interop) and is intentionally not supported.

Experimental: pomegranate is new and has no NIP yet — kinds/endpoints are provisional and may change. It adds a FROST dependency (@fiatjaf/promenade-trusted-dealer). Trust model: any threshold of colluding operators, or a malicious Google OAuth, could reconstruct the key; availability needs a threshold of operators online.

Superseded: 1.6's experimental relay-published cross-client backup (the cloud-key-backup NIP draft) is removed in 1.7 in favour of this — it avoided pomegranate's public-honeypot problem is the reason. The NIP draft is kept for the record.

Choosing a central: interop vs. self-hosting

The single most important thing to understand: in pomegranate the central is the identity anchor, not the email. Each central+operators deployment holds a different FROST-sharded key. The Google email is only the auth factor and the discovery lookup key. So "same email everywhere → same key" holds only when every client points at the same central. Two centrals for one email = two different npubs; there is no protocol path to merge them, and discovery (a kind:16440 keyed by argon2id(email)) only points, it can't adjudicate.

That leaves a real choice:

  • Interop (recommended for onboarding). Use pomegranate: true — the auth.njump.me ecosystem. Your users get the same identity they'd get in Jumble or any other njump-based app. You run nothing and depend on no servers of your own; you also can't recover/replace keys you don't operate.
  • Self-host (independence). Run your own central + operators and pass them explicitly. You control the infrastructure, but identities under your central are a separate namespace — a user who also opens a njump-based app gets a different key there. Set pinCentral: true so mill never follows discovery to another central (no surprise redirect to njump); the trade-off is you opt out of cross-central discovery entirely.

You can't have both "my own central" and "the same key njump gives me." If you want your own infra and one stable identity across the ecosystem, the only way is bring-your-own-key into two centrals: back up the key, then Import my key at auth.njump.me and at your own central. Both resolve to the same npub, so any client lands on the same identity and you get central failover — at the cost of more operators holding shards (a bigger collusion surface) and having to rotate on both. This only works for keys you hold; a normie who signs up fresh on njump gets njump's generated key, which your central can't adopt unless they import it. So for onboarding non-technical users, converging on auth.njump.me is the least-surprising choice.

Discovery needs a shared relay set. Cross-client discovery only works if every client publishes/queries the same relays and actually publishes the kind:16440. mill and fiatjaf's client both default to damus/primal/nos.lol/ nostr.mom/offchain. If a client publishes elsewhere (or not at all), mill won't find that account and will treat the email as new — a silent way to end up with two keys.

When discovery points at a different central, mill doesn't silently follow it (and never auto-opens a second popup). It shows an "Account Found Elsewhere" screen: on sign-in, one button to continue there (uses the identity you already have); on "Use a different key", the choice to replace the key there or import here at the configured central — importing publishes a fresh announcement that supersedes the old pointer, which is how a migration self-heals. Since pinCentral now defaults to true, this only appears when a host sets pinCentral: false.

Advanced servers & operator downtime

The idle screen is deliberately bare — Continue with Google and a collapsed ▸ Advanced. Everything else (how-it-works, servers, and Recover my key) lives inside Advanced, so most people never see it:

▸ Advanced
  How this works …
  Central server   [ auth.njump.me (default) ▾ ]   (+ centralChoices, "Custom…")
  Operators        ☑ ● po.f7z.io          ☑ ● po.coracle.social
                   ☑ ● po.njump.me         ☑ ● po.jumble.social  (red ● = not responding)
                   + Add operator…  [https://po.example.com] [Add]
  Any 3 of the 4 selected operators can sign.
  Applies to new accounts — existing accounts keep their recorded operators.
  Recover my key from operators                          Reset to defaults

Returning users (an account already exists for the Google account) connect in a single step — Continue with Google goes straight to the Connected screen, no extra gate. Swapping the key is offered there: the Connected screen carries a quiet Use a different key action (alongside "Disconnect & Switch Account") that jumps into the replace flow — replacing happens after Google links the account, never as a pre-login link that would fire a popup just to learn the email.

  • Status dots come from a 3 s CORS health probe when Advanced opens (and on Add): green = responding, red = not responding. Informational only.
  • The threshold is read-only, recomputed from the selected count (min(n, max(2, ceil(7n/12))); an explicit host threshold is honoured while ≤ n−1). There's no manual threshold input — it's the easiest way to lock yourself out.
  • A one-line summary appears under the button only when the selection differs from the defaults (auth.njump.me · N operators, M needed), so the default view stays clean but a custom choice is never invisible. Custom selections are remembered in localStorage after a successful sign-in; "Reset to defaults" clears them. allowCustomCentral: false / allowCustomOperators: false white-label the rows away.

Operator downtime is tolerated at signup. Registration is the one step that needs every listed operator to store a shard, so mill probes first and, if an operator is unreachable or errors (5xx) during signup/replace, leaves it out (re-dealing the same key across the rest) rather than failing — down to minOperators (default 3). The user only sees a soft one-line note ("1 operator was left out"); the raw server responses from every step (probe, register, operator errors, retries, connect) are collected under a collapsed Details disclosure, and onConnected's result.pomegranate.skipped carries the machine form. Signing needs nothing extra (central picks any threshold subset), and recovery already works with whichever operators answer. An account's operator set is fixed at signup, so this only applies to new accounts; a 4xx (e.g. a stale-shard 403) is surfaced, never silently skipped.


Continue with Google (Drive + PIN) — per-app, no external servers

A simpler path with no servers to run: mill generates and holds the key, the user sets a PIN (4–8 letters or numbers), and their nsec is encrypted into their own Google Drive (the hidden appDataFolder). It is per-app — Drive's app-data folder is scoped per OAuth client, so this is not cross-client (use pomegranate for that). Returning users sign in with their PIN; at setup they can import an existing key; "Take control of my keys" reveals the nsec and exports a portable NIP-49 ncryptsec.

It needs a small static OAuth shim on an origin you own — see shim/mill-oauth.html.

MILL.open({ oauthShim: 'https://auth.yourdomain.com/mill-oauth.html' });

When either Google path is configured, Google appears as a first-class sign-in option (with the real Google logo) — both as a card in the picker and under "I'm new here", so new and returning users can reach it. It also slots into an explicit methods list like any other method, in whatever order you want:

MILL.open({ oauthShim: '…', methods: ['google', 'nip07', 'privatekey'] });

Without an oauthShim, google is hidden from the default picker (listing it explicitly still shows it, then a clear "not configured" screen). The Google mark keeps its brand colours; everything around it — card, badge, buttons — follows your theme.

One-time setup (free, no billing account):

  1. Deploy shim/mill-oauth.html to a stable origin you own, and set MILL_CLIENT_ID + MILL_ALLOWED_ORIGINS inside it.
  2. Google Cloud Console → create an OAuth Client ID (Web application), add the shim's origin under Authorized JavaScript origins, and enable the Drive API.

Why the shim exists: drive.appdata is scoped per OAuth client, so a per-host client id would give each app a separate folder for the same user and fragment their identity. One shared client id on one origin makes "log in with Google" mean the same Nostr identity everywhere. The shim holds no secret — a client id is public, and the registered origin is the security boundary. Because the data belongs to the GCP project, not the domain, you can move the shim to a new origin later and users keep their backups.

drive.appdata is classified non-sensitive, so the consent screen needs no Google security review to publish.

On the PIN, honestly: a 4-digit PIN is ~13 bits of entropy. Measured against the 600k-iteration KDF, the whole PIN space falls in ~1s at modest parallelism once an attacker already has the ciphertext. The PIN stops casual access; the real protection is the user's Google account and its 2FA. The UI says as much rather than implying more. For at-rest security that does not depend on the account, users export a passphrase-protected ncryptsec.


Signing consent (private key only)

When mill holds the key itself, it acts as the signer — so it owns the approval UX. NIP-07, NIP-46 and NIP-55 approve requests inside their own extension or app, and mill stays out of the way.

There are two independent gates, deliberately not fused:

| Gate | Question | Cost | |---|---|---| | Unlock | Do we have your key? | Password, once per session | | Consent | Do you approve this event? | Approve/reject, per kind |

Fusing them forces a choice between a password per signature (which users turn off immediately) and no review at all. Splitting them means a request can be shown to you without costing a password. This mirrors Amber, whose biometric gate wraps the app and is skipped entirely once a permission is remembered.

The key is encrypted at rest, so the first signature after a page load always costs a password — that's the cipher, not policy.

Consent card

Shown when neither a per-kind grant nor the category pre-approval has already authorised a request. It names what is being signed (wants you to sign an Article), identifies the account, and hides the payload behind Show details — kind, date, decoded content and tags. Unknown kinds fall back to the event's alt tag, then to Event kind N.

The user picks how long to remember the answer — Just this time (default, stores nothing), 5 minutes, 1 hour, This session, Always — and the choice applies to Reject as well as Approve, so "block this kind for this session" is one interaction.

Grants are keyed per kind, so approving a Note never authorises an Article. Always grants persist in localStorage; everything else lives in sessionStorage and dies with the tab, alongside the key it authorises.

Managing permissions

The consent card links to a permissions manager, so no host wiring is required — mill is only on screen when it's asking for something, which makes that the natural entry point. If you'd rather offer a direct route:

MILL.openSettings();   // per-kind grants: Allow / Block / Ask, plus Forget all

Security notes

  • Private key flows: nsec is encrypted with AES-256-GCM (PBKDF2, 100k iterations) and stored only in sessionStorage — wiped on tab close.
  • Signing consent: the password is a session unlock, not a per-event gate. Once unlocked, the decrypted key is held in memory for the tab — so a remembered grant signs without further prompting. Consent limits what gets signed; it is not a defence against script execution on your own origin.
  • NIP-07: MILL never sees the private key. Only the public key and completed signed events pass through.
  • NIP-46: Only signed event payloads travel over the relay — never the key.
  • NIP-55: On-device intent — no network between apps.

NIP-55 (Amber direct) — opt-in only

NIP-55 is hidden from the default modal, but not because it fails to connect — as of v1.6.0 mill returns results via the clipboard, which needs no callback route, no server, and no host-app code at all.

It stays hidden because Amber 6.2.2+ deliberately refuses to remember approvals for browser callers. Web pages arrive with no calling package, so they all share a single null identity; rather than let them share one grant, Amber forces always-ask. The practical effect is that every single signature costs a full app switch — fine for signing in, painful for anything else.

For most apps, use NIP-46 with Amber-as-bunker instead. Amber registers the nostrconnect:// scheme, so mill's Remote Signer flow hands off to it directly: the user approves once, and all later signing happens over relays with no app switching. This is what Coracle, nostr-login, and most other web clients do.

To opt in to NIP-55 anyway:

MILL.open({
  methods: ['nip07', 'nip46', 'nip55', 'newkey', 'privatekey', 'readonly'],
  onConnected: handleSignIn,
});

How the result comes back

Amber's sendResult() has three branches, chosen by what you send:

| You send | Amber does | |---|---| | A calling package (native app) | setResult() back to the caller | | A callbackUrl | Fires ACTION_VIEW at callbackUrl + urlEncode(result) | | Neither | Copies the result to the clipboard ← mill's default |

Mill defaults to the clipboard branch. It snapshots the clipboard before firing the intent (so stale content is never misread), then reads it back on visibilitychange/focus when you return from Amber, validating that the text looks like a pubkey, signature, or signed event. Requires HTTPS and a one-time clipboard-read permission grant.

If you want a callback URL instead

Set amber-callback / amberCallback. Two things are worth knowing, because both have bitten people:

Amber does not append a parameter name. It literally concatenates: callbackUrl + Uri.encode(result). A URL like https://yoursite.com/amber-callback therefore produces https://yoursite.com/amber-callbackab12cd… — the result is glued onto the path and the ?event= you were expecting never exists. Your callback URL must already end in the separator and parameter name.

Amber ≥ 6.0.0 shreds query strings in the callback URL. It URL-decodes the whole intent URI and then splits on ?, so anything after a ? inside your callback URL is silently dropped (regression in 18db8c3d). Percent-encoding does not help — the decode happens first. This broke every ?event= callback in the wild as of Amber 6.0.0 (April 2026).

Mill handles both for you: it normalises whatever you pass to a #event= fragment, which survives both the old and new parsers. Fragments are also never sent to the server, so the signature stays out of your access logs.

<nostr-signer amber-callback="https://yoursite.com/amber-callback" app-name="My App"></nostr-signer>
<!-- mill sends: https://yoursite.com/amber-callback#event= -->

Because the result now arrives in a fragment, a purely static page is enough — there is no server-side step. If the callback lands on a different page from the one that opened Amber, call deliverAmberCallback() there to forward it.

What deliverAmberCallback() does

When the callback page is in a popup / new tab opened by mill:

  • Reads the result from #event= (or a legacy ?event= / ?error=) in the URL
  • Writes it to localStorage (key: mill:amber:result) — survives reloads
  • Posts a message to window.opener if present
  • Auto-closes the callback window if autoClose: true

Mill's host-page awaitAmberResult listener picks it up via the storage event, hashchange, or postMessage, and the original modal advances to the success step. localStorage is the load-bearing path here — Amber's ACTION_VIEW usually opens a fresh tab (possibly in a different browser) with no window.opener, so postMessage often has nothing to talk to.


Browser support

Modern browsers with Shadow DOM v1, CSS custom properties, and crypto.subtle (all evergreen browsers). No IE11.


License

MIT © 0ceanslim