Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac621bbaa | ||
|
|
9f0b0638e5 |
211
17.md
211
17.md
@@ -1,211 +0,0 @@
|
|||||||
NIP-17
|
|
||||||
======
|
|
||||||
|
|
||||||
Private Direct Messages
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
`draft` `optional`
|
|
||||||
|
|
||||||
This NIP defines an encrypted direct messaging scheme using [NIP-44](44.md) encryption and [NIP-59](59.md) seals and gift wraps.
|
|
||||||
|
|
||||||
## Direct Message Kind
|
|
||||||
|
|
||||||
Kind `14` is a chat message. `p` tags identify one or more receivers of the message.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"id": "<usual hash>",
|
|
||||||
"pubkey": "<sender-pubkey>",
|
|
||||||
"created_at": "<current-time>",
|
|
||||||
"kind": 14,
|
|
||||||
"tags": [
|
|
||||||
["p", "<receiver-1-pubkey>", "<relay-url>"],
|
|
||||||
["p", "<receiver-2-pubkey>", "<relay-url>"],
|
|
||||||
["e", "<kind-14-id>", "<relay-url>"] // if this is a reply
|
|
||||||
["subject", "<conversation-title>"],
|
|
||||||
// rest of tags...
|
|
||||||
],
|
|
||||||
"content": "<message-in-plain-text>",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`.content` MUST be plain text. Fields `id` and `created_at` are required.
|
|
||||||
|
|
||||||
An `e` tag denotes the direct parent message this post is replying to.
|
|
||||||
|
|
||||||
`q` tags MAY be used when citing events in the `.content` with [NIP-21](21.md).
|
|
||||||
|
|
||||||
```json
|
|
||||||
["q", "<event-id> or <event-address>", "<relay-url>", "<pubkey-if-a-regular-event>"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Kind `14`s MUST never be signed. If it is signed, the message might leak to relays and become **fully public**.
|
|
||||||
|
|
||||||
## File Message Kind
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"id": "<usual hash>",
|
|
||||||
"pubkey": "<sender-pubkey>",
|
|
||||||
"created_at": "<current-time>",
|
|
||||||
"kind": 15,
|
|
||||||
"tags": [
|
|
||||||
["p", "<receiver-1-pubkey>", "<relay-url>"],
|
|
||||||
["p", "<receiver-2-pubkey>", "<relay-url>"],
|
|
||||||
["e", "<kind-14-id>", "<relay-url>", "reply"], // if this is a reply
|
|
||||||
["subject", "<conversation-title>"],
|
|
||||||
["file-type", "<file-mime-type>"],
|
|
||||||
["encryption-algorithm", "<encryption-algorithm>"],
|
|
||||||
["decryption-key", "<decryption-key>"],
|
|
||||||
["decryption-nonce", "<decryption-nonce>"],
|
|
||||||
["x", "<the SHA-256 hexencoded string of the file>"],
|
|
||||||
// rest of tags...
|
|
||||||
],
|
|
||||||
"content": "<file-url>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Kind `15` is used for sending encrypted file event messages:
|
|
||||||
|
|
||||||
- `file-type`: Specifies the MIME type of the attached file (e.g., `image/jpeg`, `audio/mpeg`, etc.) before encryption.
|
|
||||||
- `encryption-algorithm`: Indicates the encryption algorithm used for encrypting the file. Supported algorithms: `aes-gcm`.
|
|
||||||
- `decryption-key`: The decryption key that will be used by the recipient to decrypt the file.
|
|
||||||
- `decryption-nonce`: The decryption nonce that will be used by the recipient to decrypt the file.
|
|
||||||
- `content`: The URL of the file (`<file-url>`).
|
|
||||||
- `x` containing the SHA-256 hexencoded string of the encrypted file.
|
|
||||||
- `ox` containing the SHA-256 hexencoded string of the file before encryption.
|
|
||||||
- `size` (optional) size of the encrypted file in bytes
|
|
||||||
- `dim` (optional) size in pixels in the form `<width>x<height>`
|
|
||||||
- `blurhash`(optional) the [blurhash](https://github.com/woltapp/blurhash) to show while the client is loading the file
|
|
||||||
- `thumb` (optional) URL of thumbnail with same aspect ratio (encrypted with the same key, nonce)
|
|
||||||
- `fallback` (optional) zero or more fallback file sources in case `url` fails (encrypted with the same key, nonce)
|
|
||||||
|
|
||||||
Just like kind `14`, kind `15`s MUST never be signed.
|
|
||||||
|
|
||||||
## Chat Rooms
|
|
||||||
|
|
||||||
The set of `pubkey` + `p` tags defines a chat room. If a new `p` tag is added or a current one is removed, a new room is created with a clean message history.
|
|
||||||
|
|
||||||
Clients SHOULD render messages of the same room in a continuous thread.
|
|
||||||
|
|
||||||
An optional `subject` tag defines the current name/topic of the conversation. Any member can change the topic by simply submitting a new `subject` to an existing `pubkey` + `p` tags room. There is no need to send `subject` in every message. The newest `subject` in the chat room is the subject of the conversation.
|
|
||||||
|
|
||||||
## Encrypting
|
|
||||||
|
|
||||||
Following [NIP-59](59.md), the **unsigned** `kind:14` & `kind:15` chat messages must be sealed (`kind:13`) and then gift-wrapped (`kind:1059`) to each receiver and the sender individually.
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"id": "<usual hash>",
|
|
||||||
"pubkey": randomPublicKey,
|
|
||||||
"created_at": randomTimeUpTo2DaysInThePast(),
|
|
||||||
"kind": 1059, // gift wrap
|
|
||||||
"tags": [
|
|
||||||
["p", receiverPublicKey, "<relay-url>"] // receiver
|
|
||||||
],
|
|
||||||
"content": nip44Encrypt(
|
|
||||||
{
|
|
||||||
"id": "<usual hash>",
|
|
||||||
"pubkey": senderPublicKey,
|
|
||||||
"created_at": randomTimeUpTo2DaysInThePast(),
|
|
||||||
"kind": 13, // seal
|
|
||||||
"tags": [], // no tags
|
|
||||||
"content": nip44Encrypt(unsignedKind14, senderPrivateKey, receiverPublicKey),
|
|
||||||
"sig": "<signed by senderPrivateKey>"
|
|
||||||
},
|
|
||||||
randomPrivateKey, receiverPublicKey
|
|
||||||
),
|
|
||||||
"sig": "<signed by randomPrivateKey>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The encryption algorithm MUST use the latest version of [NIP-44](44.md).
|
|
||||||
|
|
||||||
Clients MUST verify if pubkey of the `kind:13` is the same pubkey on the `kind:14`, otherwise any sender can impersonate others by simply changing the pubkey on `kind:14`.
|
|
||||||
|
|
||||||
Clients SHOULD randomize `created_at` in up to two days in the past in both the seal and the gift wrap to make sure grouping by `created_at` doesn't reveal any metadata.
|
|
||||||
|
|
||||||
The gift wrap's `p` tag can be the receiver's main pubkey or an alias key created to receive DMs without exposing the receiver's identity.
|
|
||||||
|
|
||||||
Clients CAN offer disappearing messages by setting an `expiration` tag in the gift wrap of each receiver or by not generating a gift wrap to the sender's public key
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
Kind `10050` indicates the user's preferred relays to receive DMs. The event MUST include a list of `relay` tags with relay URIs.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"kind": 10050,
|
|
||||||
"tags": [
|
|
||||||
["relay", "wss://inbox.nostr.wine"],
|
|
||||||
["relay", "wss://myrelay.nostr1.com"],
|
|
||||||
],
|
|
||||||
"content": "",
|
|
||||||
// other fields...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clients SHOULD publish kind `14` events to the `10050`-listed relays. If that is not found that indicates the user is not ready to receive messages under this NIP and clients shouldn't try.
|
|
||||||
|
|
||||||
## Relays
|
|
||||||
|
|
||||||
It's advisable that relays do not serve `kind:1059` to clients other than the ones tagged in them.
|
|
||||||
|
|
||||||
It's advisable that users choose relays that conform to these practices.
|
|
||||||
|
|
||||||
Clients SHOULD guide users to keep `kind:10050` lists small (1-3 relays) and SHOULD spread it to as many relays as viable.
|
|
||||||
|
|
||||||
## Benefits & Limitations
|
|
||||||
|
|
||||||
This NIP offers the following privacy and security features:
|
|
||||||
|
|
||||||
1. **No Metadata Leak**: Participant identities, each message's real date and time, event kinds, and other event tags are all hidden from the public. Senders and receivers cannot be linked with public information alone.
|
|
||||||
2. **No Public Group Identifiers**: There is no public central queue, channel or otherwise converging identifier to correlate or count all messages in the same group.
|
|
||||||
3. **No Moderation**: There are no group admins: no invitations or bans.
|
|
||||||
4. **No Shared Secrets**: No secret must be known to all members that can leak or be mistakenly shared
|
|
||||||
5. **Fully Recoverable**: Messages can be fully recoverable by any client with the user's private key
|
|
||||||
6. **Optional Forward Secrecy**: Users and clients can opt-in for "disappearing messages".
|
|
||||||
7. **Uses Public Relays**: Messages can flow through public relays without loss of privacy. Private relays can increase privacy further, but they are not required.
|
|
||||||
8. **Cold Storage**: Users can unilaterally opt-in to sharing their messages with a separate key that is exclusive for DM backup and recovery.
|
|
||||||
|
|
||||||
The main limitation of this approach is having to send a separate encrypted event to each receiver. Group chats with more than 100 participants should find a more suitable messaging scheme.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Clients implementing this NIP should by default only connect to the set of relays found in their `kind:10050` list. From that they should be able to load all messages both sent and received as well as get new live updates, making it for a very simple and lightweight implementation that should be fast.
|
|
||||||
|
|
||||||
When sending a message to anyone, clients must then connect to the relays in the receiver's `kind:10050` and send the events there but can disconnect right after unless more messages are expected to be sent (e.g. the chat tab is still selected). Clients should also send a copy of their outgoing messages to their own `kind:10050` relay set.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
This example sends the message `Hola, que tal?` from `nsec1w8udu59ydjvedgs3yv5qccshcj8k05fh3l60k9x57asjrqdpa00qkmr89m` to `nsec12ywtkplvyq5t6twdqwwygavp5lm4fhuang89c943nf2z92eez43szvn4dt`.
|
|
||||||
|
|
||||||
The two final GiftWraps, one to the receiver and the other to the sender, respectively, are:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id":"2886780f7349afc1344047524540ee716f7bdc1b64191699855662330bf235d8",
|
|
||||||
"pubkey":"8f8a7ec43b77d25799281207e1a47f7a654755055788f7482653f9c9661c6d51",
|
|
||||||
"created_at":1703128320,
|
|
||||||
"kind":1059,
|
|
||||||
"tags":[
|
|
||||||
["p", "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788"]
|
|
||||||
],
|
|
||||||
"content":"AsqzdlMsG304G8h08bE67dhAR1gFTzTckUUyuvndZ8LrGCvwI4pgC3d6hyAK0Wo9gtkLqSr2rT2RyHlE5wRqbCOlQ8WvJEKwqwIJwT5PO3l2RxvGCHDbd1b1o40ZgIVwwLCfOWJ86I5upXe8K5AgpxYTOM1BD+SbgI5jOMA8tgpRoitJedVSvBZsmwAxXM7o7sbOON4MXHzOqOZpALpS2zgBDXSAaYAsTdEM4qqFeik+zTk3+L6NYuftGidqVluicwSGS2viYWr5OiJ1zrj1ERhYSGLpQnPKrqDaDi7R1KrHGFGyLgkJveY/45y0rv9aVIw9IWF11u53cf2CP7akACel2WvZdl1htEwFu/v9cFXD06fNVZjfx3OssKM/uHPE9XvZttQboAvP5UoK6lv9o3d+0GM4/3zP+yO3C0NExz1ZgFmbGFz703YJzM+zpKCOXaZyzPjADXp8qBBeVc5lmJqiCL4solZpxA1865yPigPAZcc9acSUlg23J1dptFK4n3Tl5HfSHP+oZ/QS/SHWbVFCtq7ZMQSRxLgEitfglTNz9P1CnpMwmW/Y4Gm5zdkv0JrdUVrn2UO9ARdHlPsW5ARgDmzaxnJypkfoHXNfxGGXWRk0sKLbz/ipnaQP/eFJv/ibNuSfqL6E4BnN/tHJSHYEaTQ/PdrA2i9laG3vJti3kAl5Ih87ct0w/tzYfp4SRPhEF1zzue9G/16eJEMzwmhQ5Ec7jJVcVGa4RltqnuF8unUu3iSRTQ+/MNNUkK6Mk+YuaJJs6Fjw6tRHuWi57SdKKv7GGkr0zlBUU2Dyo1MwpAqzsCcCTeQSv+8qt4wLf4uhU9Br7F/L0ZY9bFgh6iLDCdB+4iABXyZwT7Ufn762195hrSHcU4Okt0Zns9EeiBOFxnmpXEslYkYBpXw70GmymQfJlFOfoEp93QKCMS2DAEVeI51dJV1e+6t3pCSsQN69Vg6jUCsm1TMxSs2VX4BRbq562+VffchvW2BB4gMjsvHVUSRl8i5/ZSDlfzSPXcSGALLHBRzy+gn0oXXJ/447VHYZJDL3Ig8+QW5oFMgnWYhuwI5QSLEyflUrfSz+Pdwn/5eyjybXKJftePBD9Q+8NQ8zulU5sqvsMeIx/bBUx0fmOXsS3vjqCXW5IjkmSUV7q54GewZqTQBlcx+90xh/LSUxXex7UwZwRnifvyCbZ+zwNTHNb12chYeNjMV7kAIr3cGQv8vlOMM8ajyaZ5KVy7HpSXQjz4PGT2/nXbL5jKt8Lx0erGXsSsazkdoYDG3U",
|
|
||||||
"sig":"a3c6ce632b145c0869423c1afaff4a6d764a9b64dedaf15f170b944ead67227518a72e455567ca1c2a0d187832cecbde7ed478395ec4c95dd3e71749ed66c480"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id":"162b0611a1911cfcb30f8a5502792b346e535a45658b3a31ae5c178465509721",
|
|
||||||
"pubkey":"626be2af274b29ea4816ad672ee452b7cf96bbb4836815a55699ae402183f512",
|
|
||||||
"created_at":1702711587,
|
|
||||||
"kind":1059,
|
|
||||||
"tags":[
|
|
||||||
["p", "44900586091b284416a0c001f677f9c49f7639a55c3f1e2ec130a8e1a7998e1b"]
|
|
||||||
],
|
|
||||||
"content":"AsTClTzr0gzXXji7uye5UB6LYrx3HDjWGdkNaBS6BAX9CpHa+Vvtt5oI2xJrmWLen+Fo2NBOFazvl285Gb3HSM82gVycrzx1HUAaQDUG6HI7XBEGqBhQMUNwNMiN2dnilBMFC3Yc8ehCJT/gkbiNKOpwd2rFibMFRMDKai2mq2lBtPJF18oszKOjA+XlOJV8JRbmcAanTbEK5nA/GnG3eGUiUzhiYBoHomj3vztYYxc0QYHOx0WxiHY8dsC6jPsXC7f6k4P+Hv5ZiyTfzvjkSJOckel1lZuE5SfeZ0nduqTlxREGeBJ8amOykgEIKdH2VZBZB+qtOMc7ez9dz4wffGwBDA7912NFS2dPBr6txHNxBUkDZKFbuD5wijvonZDvfWq43tZspO4NutSokZB99uEiRH8NAUdGTiNb25m9JcDhVfdmABqTg5fIwwTwlem5aXIy8b66lmqqz2LBzJtnJDu36bDwkILph3kmvaKPD8qJXmPQ4yGpxIbYSTCohgt2/I0TKJNmqNvSN+IVoUuC7ZOfUV9lOV8Ri0AMfSr2YsdZ9ofV5o82ClZWlWiSWZwy6ypa7CuT1PEGHzywB4CZ5ucpO60Z7hnBQxHLiAQIO/QhiBp1rmrdQZFN6PUEjFDloykoeHe345Yqy9Ke95HIKUCS9yJurD+nZjjgOxZjoFCsB1hQAwINTIS3FbYOibZnQwv8PXvcSOqVZxC9U0+WuagK7IwxzhGZY3vLRrX01oujiRrevB4xbW7Oxi/Agp7CQGlJXCgmRE8Rhm+Vj2s+wc/4VLNZRHDcwtfejogjrjdi8p6nfUyqoQRRPARzRGUnnCbh+LqhigT6gQf3sVilnydMRScEc0/YYNLWnaw9nbyBa7wFBAiGbJwO40k39wj+xT6HTSbSUgFZzopxroO3f/o4+ubx2+IL3fkev22mEN38+dFmYF3zE+hpE7jVxrJpC3EP9PLoFgFPKCuctMnjXmeHoiGs756N5r1Mm1ffZu4H19MSuALJlxQR7VXE/LzxRXDuaB2u9days/6muP6gbGX1ASxbJd/ou8+viHmSC/ioHzNjItVCPaJjDyc6bv+gs1NPCt0qZ69G+JmgHW/PsMMeL4n5bh74g0fJSHqiI9ewEmOG/8bedSREv2XXtKV39STxPweceIOh0k23s3N6+wvuSUAJE7u1LkDo14cobtZ/MCw/QhimYPd1u5HnEJvRhPxz0nVPz0QqL/YQeOkAYk7uzgeb2yPzJ6DBtnTnGDkglekhVzQBFRJdk740LEj6swkJ",
|
|
||||||
"sig":"c94e74533b482aa8eeeb54ae72a5303e0b21f62909ca43c8ef06b0357412d6f8a92f96e1a205102753777fd25321a58fba3fb384eee114bd53ce6c06a1c22bab"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
297
44.md
297
44.md
@@ -1,297 +0,0 @@
|
|||||||
NIP-44
|
|
||||||
======
|
|
||||||
|
|
||||||
Encrypted Payloads (Versioned)
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
`optional`
|
|
||||||
|
|
||||||
The NIP introduces a new data format for keypair-based encryption. This NIP is versioned
|
|
||||||
to allow multiple algorithm choices to exist simultaneously. This format may be used for
|
|
||||||
many things, but MUST be used in the context of a signed event as described in NIP-01.
|
|
||||||
|
|
||||||
*Note*: this format DOES NOT define any `kind`s related to a new direct messaging standard,
|
|
||||||
only the encryption required to define one. It SHOULD NOT be used as a drop-in replacement
|
|
||||||
for NIP-04 payloads.
|
|
||||||
|
|
||||||
## Versions
|
|
||||||
|
|
||||||
Currently defined encryption algorithms:
|
|
||||||
|
|
||||||
- `0x00` - Reserved
|
|
||||||
- `0x01` - Deprecated and undefined
|
|
||||||
- `0x02` - secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
Every nostr user has their own public key, which solves key distribution problems present
|
|
||||||
in other solutions. However, nostr's relay-based architecture makes it difficult to implement
|
|
||||||
more robust private messaging protocols with things like metadata hiding, forward secrecy,
|
|
||||||
and post compromise secrecy.
|
|
||||||
|
|
||||||
The goal of this NIP is to have a _simple_ way to encrypt payloads used in the context of a signed
|
|
||||||
event. When applying this NIP to any use case, it's important to keep in mind your users' threat
|
|
||||||
model and this NIP's limitations. For high-risk situations, users should chat in specialized E2EE
|
|
||||||
messaging software and limit use of nostr to exchanging contacts.
|
|
||||||
|
|
||||||
On its own, messages sent using this scheme have a number of important shortcomings:
|
|
||||||
|
|
||||||
- No deniability: it is possible to prove an event was signed by a particular key
|
|
||||||
- No forward secrecy: when a key is compromised, it is possible to decrypt all previous conversations
|
|
||||||
- No post-compromise security: when a key is compromised, it is possible to decrypt all future conversations
|
|
||||||
- No post-quantum security: a powerful quantum computer would be able to decrypt the messages
|
|
||||||
- IP address leak: user IP may be seen by relays and all intermediaries between user and relay
|
|
||||||
- Date leak: `created_at` is public, since it is a part of NIP-01 event
|
|
||||||
- Limited message size leak: padding only partially obscures true message length
|
|
||||||
- No attachments: they are not supported
|
|
||||||
|
|
||||||
Lack of forward secrecy may be partially mitigated by only sending messages to trusted relays, and asking
|
|
||||||
relays to delete stored messages after a certain duration has elapsed.
|
|
||||||
|
|
||||||
## Version 2
|
|
||||||
|
|
||||||
NIP-44 version 2 has the following design characteristics:
|
|
||||||
|
|
||||||
- Payloads are authenticated using a MAC before signing rather than afterwards because events are assumed
|
|
||||||
to be signed as specified in NIP-01. The outer signature serves to authenticate the full payload, and MUST
|
|
||||||
be validated before decrypting.
|
|
||||||
- ChaCha is used instead of AES because it's faster and has
|
|
||||||
[better security against multi-key attacks](https://datatracker.ietf.org/doc/draft-irtf-cfrg-aead-limits/).
|
|
||||||
- ChaCha is used instead of XChaCha because XChaCha has not been standardized. Also, xChaCha's improved collision
|
|
||||||
resistance of nonces isn't necessary since every message has a new (key, nonce) pair.
|
|
||||||
- HMAC-SHA256 is used instead of Poly1305 because polynomial MACs are much easier to forge.
|
|
||||||
- SHA256 is used instead of SHA3 or BLAKE because it is already used in nostr. Also BLAKE's speed advantage
|
|
||||||
is smaller in non-parallel environments.
|
|
||||||
- A custom padding scheme is used instead of padmé because it provides better leakage reduction for small messages.
|
|
||||||
- Base64 encoding is used instead of another encoding algorithm because it is widely available, and is already used in nostr.
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
|
|
||||||
1. Calculate a conversation key
|
|
||||||
- Execute ECDH (scalar multiplication) of public key B by private key A
|
|
||||||
Output `shared_x` must be unhashed, 32-byte encoded x coordinate of the shared point
|
|
||||||
- Use HKDF-extract with sha256, `IKM=shared_x` and `salt=utf8_encode('nip44-v2')`
|
|
||||||
- HKDF output will be a `conversation_key` between two users.
|
|
||||||
- It is always the same, when key roles are swapped: `conv(a, B) == conv(b, A)`
|
|
||||||
2. Generate a random 32-byte nonce
|
|
||||||
- Always use [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator)
|
|
||||||
- Don't generate a nonce from message content
|
|
||||||
- Don't re-use the same nonce between messages: doing so would make them decryptable,
|
|
||||||
but won't leak the long-term key
|
|
||||||
3. Calculate message keys
|
|
||||||
- The keys are generated from `conversation_key` and `nonce`. Validate that both are 32 bytes long
|
|
||||||
- Use HKDF-expand, with sha256, `PRK=conversation_key`, `info=nonce` and `L=76`
|
|
||||||
- Slice 76-byte HKDF output into: `chacha_key` (bytes 0..32), `chacha_nonce` (bytes 32..44), `hmac_key` (bytes 44..76)
|
|
||||||
4. Add padding
|
|
||||||
- Content must be encoded from UTF-8 into byte array
|
|
||||||
- Validate plaintext length. Minimum is 1 byte, maximum is 65535 bytes
|
|
||||||
- Padding format is: `[plaintext_length: u16][plaintext][zero_bytes]`
|
|
||||||
- Padding algorithm is related to powers-of-two, with min padded msg size of 32 bytes
|
|
||||||
- Plaintext length is encoded in big-endian as first 2 bytes of the padded blob
|
|
||||||
5. Encrypt padded content
|
|
||||||
- Use ChaCha20, with key and nonce from step 3
|
|
||||||
6. Calculate MAC (message authentication code)
|
|
||||||
- AAD (additional authenticated data) is used - instead of calculating MAC on ciphertext,
|
|
||||||
it's calculated over a concatenation of `nonce` and `ciphertext`
|
|
||||||
- Validate that AAD (nonce) is 32 bytes
|
|
||||||
7. Base64-encode (with padding) params using `concat(version, nonce, ciphertext, mac)`
|
|
||||||
|
|
||||||
Encrypted payloads MUST be included in an event's payload, hashed, and signed as defined in NIP 01, using schnorr
|
|
||||||
signature scheme over secp256k1.
|
|
||||||
|
|
||||||
### Decryption
|
|
||||||
|
|
||||||
Before decryption, the event's pubkey and signature MUST be validated as defined in NIP 01. The public key MUST be
|
|
||||||
a valid non-zero secp256k1 curve point, and the signature must be valid secp256k1 schnorr signature. For exact
|
|
||||||
validation rules, refer to BIP-340.
|
|
||||||
|
|
||||||
1. Check if first payload's character is `#`
|
|
||||||
- `#` is an optional future-proof flag that means non-base64 encoding is used
|
|
||||||
- The `#` is not present in base64 alphabet, but, instead of throwing `base64 is invalid`,
|
|
||||||
implementations MUST indicate that the encryption version is not yet supported
|
|
||||||
2. Decode base64
|
|
||||||
- Base64 is decoded into `version, nonce, ciphertext, mac`
|
|
||||||
- If the version is unknown, implementations must indicate that the encryption version is not supported
|
|
||||||
- Validate length of base64 message to prevent DoS on base64 decoder: it can be in range from 132 to 87472 chars
|
|
||||||
- Validate length of decoded message to verify output of the decoder: it can be in range from 99 to 65603 bytes
|
|
||||||
3. Calculate conversation key
|
|
||||||
- See step 1 of [encryption](#Encryption)
|
|
||||||
4. Calculate message keys
|
|
||||||
- See step 3 of [encryption](#Encryption)
|
|
||||||
5. Calculate MAC (message authentication code) with AAD and compare
|
|
||||||
- Stop and throw an error if MAC doesn't match the decoded one from step 2
|
|
||||||
- Use constant-time comparison algorithm
|
|
||||||
6. Decrypt ciphertext
|
|
||||||
- Use ChaCha20 with key and nonce from step 3
|
|
||||||
7. Remove padding
|
|
||||||
- Read the first two BE bytes of plaintext that correspond to plaintext length
|
|
||||||
- Verify that the length of sliced plaintext matches the value of the two BE bytes
|
|
||||||
- Verify that calculated padding from step 3 of the [encryption](#Encryption) process matches the actual padding
|
|
||||||
|
|
||||||
### Details
|
|
||||||
|
|
||||||
- Cryptographic methods
|
|
||||||
- `secure_random_bytes(length)` fetches randomness from CSPRNG.
|
|
||||||
- `hkdf(IKM, salt, info, L)` represents HKDF [(RFC 5869)](https://datatracker.ietf.org/doc/html/rfc5869)
|
|
||||||
with SHA256 hash function comprised of methods `hkdf_extract(IKM, salt)` and `hkdf_expand(OKM, info, L)`.
|
|
||||||
- `chacha20(key, nonce, data)` is ChaCha20 [(RFC 8439)](https://datatracker.ietf.org/doc/html/rfc8439) with
|
|
||||||
starting counter set to 0.
|
|
||||||
- `hmac_sha256(key, message)` is HMAC [(RFC 2104)](https://datatracker.ietf.org/doc/html/rfc2104).
|
|
||||||
- `secp256k1_ecdh(priv_a, pub_b)` is multiplication of point B by scalar a (`a ⋅ B`), defined in
|
|
||||||
[BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki).
|
|
||||||
The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method
|
|
||||||
`bytes(P)` from BIP340. Private and public keys must be validated as per BIP340: pubkey must be a valid,
|
|
||||||
on-curve point, and private key must be a scalar in range `[1, secp256k1_order - 1]`.
|
|
||||||
NIP44 doesn't do hashing of the output: keep this in mind, because some libraries hash it using sha256.
|
|
||||||
As an example, in libsecp256k1, unhashed version is available in `secp256k1_ec_pubkey_tweak_mul`
|
|
||||||
- Operators
|
|
||||||
- `x[i:j]`, where `x` is a byte array and `i, j <= 0` returns a `(j - i)`-byte array with a copy of the
|
|
||||||
`i`-th byte (inclusive) to the `j`-th byte (exclusive) of `x`.
|
|
||||||
- Constants `c`:
|
|
||||||
- `min_plaintext_size` is 1. 1 byte msg is padded to 32 bytes.
|
|
||||||
- `max_plaintext_size` is 65535 (64kB - 1). It is padded to 65536 bytes.
|
|
||||||
- Functions
|
|
||||||
- `base64_encode(string)` and `base64_decode(bytes)` are Base64 ([RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648), with padding)
|
|
||||||
- `concat` refers to byte array concatenation
|
|
||||||
- `is_equal_ct(a, b)` is constant-time equality check of 2 byte arrays
|
|
||||||
- `utf8_encode(string)` and `utf8_decode(bytes)` transform string to byte array and back
|
|
||||||
- `write_u8(number)` restricts number to values 0..255 and encodes into Big-Endian uint8 byte array
|
|
||||||
- `write_u16_be(number)` restricts number to values 0..65535 and encodes into Big-Endian uint16 byte array
|
|
||||||
- `zeros(length)` creates byte array of length `length >= 0`, filled with zeros
|
|
||||||
- `floor(number)` and `log2(number)` are well-known mathematical methods
|
|
||||||
|
|
||||||
### Implementation pseudocode
|
|
||||||
|
|
||||||
The following is a collection of python-like pseudocode functions which implement the above primitives,
|
|
||||||
intended to guide implementers. A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
|
|
||||||
|
|
||||||
```py
|
|
||||||
# Calculates length of the padded byte array.
|
|
||||||
def calc_padded_len(unpadded_len):
|
|
||||||
next_power = 1 << (floor(log2(unpadded_len - 1))) + 1
|
|
||||||
if next_power <= 256:
|
|
||||||
chunk = 32
|
|
||||||
else:
|
|
||||||
chunk = next_power / 8
|
|
||||||
if unpadded_len <= 32:
|
|
||||||
return 32
|
|
||||||
else:
|
|
||||||
return chunk * (floor((len - 1) / chunk) + 1)
|
|
||||||
|
|
||||||
# Converts unpadded plaintext to padded bytearray
|
|
||||||
def pad(plaintext):
|
|
||||||
unpadded = utf8_encode(plaintext)
|
|
||||||
unpadded_len = len(plaintext)
|
|
||||||
if (unpadded_len < c.min_plaintext_size or
|
|
||||||
unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length')
|
|
||||||
prefix = write_u16_be(unpadded_len)
|
|
||||||
suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
|
|
||||||
return concat(prefix, unpadded, suffix)
|
|
||||||
|
|
||||||
# Converts padded bytearray to unpadded plaintext
|
|
||||||
def unpad(padded):
|
|
||||||
unpadded_len = read_uint16_be(padded[0:2])
|
|
||||||
unpadded = padded[2:2+unpadded_len]
|
|
||||||
if (unpadded_len == 0 or
|
|
||||||
len(unpadded) != unpadded_len or
|
|
||||||
len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding')
|
|
||||||
return utf8_decode(unpadded)
|
|
||||||
|
|
||||||
# metadata: always 65b (version: 1b, nonce: 32b, max: 32b)
|
|
||||||
# plaintext: 1b to 0xffff
|
|
||||||
# padded plaintext: 32b to 0xffff
|
|
||||||
# ciphertext: 32b+2 to 0xffff+2
|
|
||||||
# raw payload: 99 (65+32+2) to 65603 (65+0xffff+2)
|
|
||||||
# compressed payload (base64): 132b to 87472b
|
|
||||||
def decode_payload(payload):
|
|
||||||
plen = len(payload)
|
|
||||||
if plen == 0 or payload[0] == '#': raise Exception('unknown version')
|
|
||||||
if plen < 132 or plen > 87472: raise Exception('invalid payload size')
|
|
||||||
data = base64_decode(payload)
|
|
||||||
dlen = len(d)
|
|
||||||
if dlen < 99 or dlen > 65603: raise Exception('invalid data size');
|
|
||||||
vers = data[0]
|
|
||||||
if vers != 2: raise Exception('unknown version ' + vers)
|
|
||||||
nonce = data[1:33]
|
|
||||||
ciphertext = data[33:dlen - 32]
|
|
||||||
mac = data[dlen - 32:dlen]
|
|
||||||
return (nonce, ciphertext, mac)
|
|
||||||
|
|
||||||
def hmac_aad(key, message, aad):
|
|
||||||
if len(aad) != 32: raise Exception('AAD associated data must be 32 bytes');
|
|
||||||
return hmac(sha256, key, concat(aad, message));
|
|
||||||
|
|
||||||
# Calculates long-term key between users A and B: `get_key(Apriv, Bpub) == get_key(Bpriv, Apub)`
|
|
||||||
def get_conversation_key(private_key_a, public_key_b):
|
|
||||||
shared_x = secp256k1_ecdh(private_key_a, public_key_b)
|
|
||||||
return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))
|
|
||||||
|
|
||||||
# Calculates unique per-message key
|
|
||||||
def get_message_keys(conversation_key, nonce):
|
|
||||||
if len(conversation_key) != 32: raise Exception('invalid conversation_key length')
|
|
||||||
if len(nonce) != 32: raise Exception('invalid nonce length')
|
|
||||||
keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
|
|
||||||
chacha_key = keys[0:32]
|
|
||||||
chacha_nonce = keys[32:44]
|
|
||||||
hmac_key = keys[44:76]
|
|
||||||
return (chacha_key, chacha_nonce, hmac_key)
|
|
||||||
|
|
||||||
def encrypt(plaintext, conversation_key, nonce):
|
|
||||||
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
|
|
||||||
padded = pad(plaintext)
|
|
||||||
ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)
|
|
||||||
mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
|
|
||||||
return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))
|
|
||||||
|
|
||||||
def decrypt(payload, conversation_key):
|
|
||||||
(nonce, ciphertext, mac) = decode_payload(payload)
|
|
||||||
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
|
|
||||||
calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
|
|
||||||
if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC')
|
|
||||||
padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
|
|
||||||
return unpad(padded_plaintext)
|
|
||||||
|
|
||||||
# Usage:
|
|
||||||
# conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)
|
|
||||||
# nonce = secure_random_bytes(32)
|
|
||||||
# payload = encrypt('hello world', conversation_key, nonce)
|
|
||||||
# 'hello world' == decrypt(payload, conversation_key)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audit
|
|
||||||
|
|
||||||
The v2 of the standard was audited by [Cure53](https://cure53.de) in December 2023.
|
|
||||||
Check out [audit-2023.12.pdf](https://github.com/paulmillr/nip44/blob/ce63c2eaf345e9f7f93b48f829e6bdeb7e7d7964/audit-2023.12.pdf)
|
|
||||||
and [auditor's website](https://cure53.de/audit-report_nip44-implementations.pdf).
|
|
||||||
|
|
||||||
### Tests and code
|
|
||||||
|
|
||||||
A collection of implementations in different languages is available at https://github.com/paulmillr/nip44.
|
|
||||||
|
|
||||||
We publish extensive test vectors. Instead of having it in the document directly, a sha256 checksum of vectors is provided:
|
|
||||||
|
|
||||||
269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json
|
|
||||||
|
|
||||||
Example of a test vector from the file:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
|
|
||||||
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
|
|
||||||
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"plaintext": "a",
|
|
||||||
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The file also contains intermediate values. A quick guidance with regards to its usage:
|
|
||||||
|
|
||||||
- `valid.get_conversation_key`: calculate conversation_key from secret key sec1 and public key pub2
|
|
||||||
- `valid.get_message_keys`: calculate chacha_key, chacha_nonce, hmac_key from conversation_key and nonce
|
|
||||||
- `valid.calc_padded_len`: take unpadded length (first value), calculate padded length (second value)
|
|
||||||
- `valid.encrypt_decrypt`: emulate real conversation. Calculate pub2 from sec2, verify conversation_key from (sec1, pub2), encrypt, verify payload, then calculate pub1 from sec1, verify conversation_key from (sec2, pub1), decrypt, verify plaintext.
|
|
||||||
- `valid.encrypt_decrypt_long_msg`: same as previous step, but instead of a full plaintext and payload, their checksum is provided.
|
|
||||||
- `invalid.encrypt_msg_lengths`
|
|
||||||
- `invalid.get_conversation_key`: calculating conversation_key must throw an error
|
|
||||||
- `invalid.decrypt`: decrypting message content must throw an error
|
|
||||||
25
README.md
25
README.md
@@ -7,14 +7,27 @@ Configure persistent floating tab for login/logout:
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
await NOSTR_LOGIN_LITE.init({
|
await NOSTR_LOGIN_LITE.init({
|
||||||
|
// Set the initial theme (default: 'default')
|
||||||
|
theme: 'dark', // Choose from 'default' or 'dark'
|
||||||
|
|
||||||
|
// Standard configuration options
|
||||||
|
methods: {
|
||||||
|
extension: true,
|
||||||
|
local: true,
|
||||||
|
readonly: true,
|
||||||
|
connect: true,
|
||||||
|
otp: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Floating tab configuration (now uses theme-aware text icons)
|
||||||
floatingTab: {
|
floatingTab: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
hPosition: 0.95, // 0.0-1.0 or '95%' from left
|
hPosition: 0.95, // 0.0-1.0 or '95%' from left
|
||||||
vPosition: 0.5, // 0.0-1.0 or '50%' from top
|
vPosition: 0.5, // 0.0-1.0 or '50%' from top
|
||||||
appearance: {
|
appearance: {
|
||||||
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
|
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
|
||||||
theme: 'auto', // 'auto', 'light', 'dark'
|
theme: 'auto', // 'auto' follows main theme
|
||||||
icon: '🔐',
|
icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
|
||||||
text: 'Login'
|
text: 'Login'
|
||||||
},
|
},
|
||||||
behavior: {
|
behavior: {
|
||||||
@@ -27,6 +40,14 @@ await NOSTR_LOGIN_LITE.init({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After initialization, you can switch themes dynamically:
|
||||||
|
NOSTR_LOGIN_LITE.switchTheme('dark');
|
||||||
|
NOSTR_LOGIN_LITE.switchTheme('default');
|
||||||
|
|
||||||
|
// Or customize individual theme variables:
|
||||||
|
NOSTR_LOGIN_LITE.setThemeVariable('--nl-accent-color', '#00ff00');
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Control methods:
|
Control methods:
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# NOSTR_LOGIN_LITE Examples
|
|
||||||
|
|
||||||
This directory contains examples and tests for NOSTR_LOGIN_LITE using the local bundle setup.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### 🔬 comprehensive-test.html
|
|
||||||
**The main diagnostic and testing tool**
|
|
||||||
- Comprehensive test suite with extensive debugging output
|
|
||||||
- Tests all library functions, dependencies, crypto, storage, etc.
|
|
||||||
- Results displayed on webpage for easy copying and debugging
|
|
||||||
- Run this when you need to diagnose issues or verify functionality
|
|
||||||
|
|
||||||
### 📱 simple-demo.html
|
|
||||||
Basic demonstration of NOSTR_LOGIN_LITE integration
|
|
||||||
- Minimal setup example
|
|
||||||
- Good starting point for new implementations
|
|
||||||
|
|
||||||
### 🎨 modal-login-demo.html
|
|
||||||
Demonstrates modal-based login flow
|
|
||||||
- Shows how to trigger and handle the login modal
|
|
||||||
- Example of auth event handling
|
|
||||||
|
|
||||||
### 👤 login-and-profile.html
|
|
||||||
Login and user profile demonstration
|
|
||||||
- Shows authentication flow
|
|
||||||
- Displays user profile information after login
|
|
||||||
|
|
||||||
### 🔗 nip46-bunker-demo.html
|
|
||||||
NIP-46 remote signing demonstration
|
|
||||||
- Shows how to connect to remote signers/bunkers
|
|
||||||
- Advanced use case example
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Start a local web server (e.g., `python -m http.server 5501` or Live Server in VS Code)
|
|
||||||
2. Navigate to any HTML file
|
|
||||||
3. For comprehensive testing and debugging, use `comprehensive-test.html`
|
|
||||||
|
|
||||||
All examples use the local bundle setup with two files:
|
|
||||||
1. `../lite/nostr.bundle.js` - Official nostr-tools bundle
|
|
||||||
2. `../lite/nostr-lite.js` - NOSTR_LOGIN_LITE library with embedded NIP-46 extension
|
|
||||||
|
|
||||||
## Architecture Update (2025-09-13)
|
|
||||||
|
|
||||||
The library has been simplified from a three-file to a two-file architecture:
|
|
||||||
- ✅ **Before:** `nostr.bundle.js` + `nip46-extension.js` + `nostr-lite.js`
|
|
||||||
- ✅ **Now:** `nostr.bundle.js` + `nostr-lite.js` (with embedded NIP-46)
|
|
||||||
|
|
||||||
All functionality remains identical - NIP-46 remote signing, all auth methods, and full compatibility are preserved.
|
|
||||||
75
examples/button.html
Normal file
75
examples/button.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-button {
|
||||||
|
background: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-button:hover {
|
||||||
|
background: #0052a3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div id="login-button">Login</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../lite/nostr.bundle.js"></script>
|
||||||
|
<script src="../lite/nostr-lite.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await window.NOSTR_LOGIN_LITE.init({
|
||||||
|
theme: 'default',
|
||||||
|
methods: {
|
||||||
|
extension: true,
|
||||||
|
local: true,
|
||||||
|
readonly: true,
|
||||||
|
connect: true,
|
||||||
|
remote: true,
|
||||||
|
otp: true
|
||||||
|
},
|
||||||
|
floatingTab: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('login-button').addEventListener('click', () => {
|
||||||
|
window.NOSTR_LOGIN_LITE.launch('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await window.NOSTR_LOGIN_LITE.init({
|
await window.NOSTR_LOGIN_LITE.init({
|
||||||
|
theme:'default',
|
||||||
methods: {
|
methods: {
|
||||||
extension: true,
|
extension: true,
|
||||||
local: true,
|
local: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -9,249 +10,33 @@
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #ffffff;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: white;
|
color: #000000;
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
background: linear-gradient(45deg, #fff, #007bff);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin: 15px 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.logged-out {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.logged-in {
|
|
||||||
background: rgba(76, 175, 80, 0.2);
|
|
||||||
border: 1px solid rgba(76, 175, 80, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.loading {
|
|
||||||
background: rgba(255, 193, 7, 0.2);
|
|
||||||
border: 1px solid rgba(255, 193, 7, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: linear-gradient(45deg, #007bff, #0056b3);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 15px 30px;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: inline-block;
|
|
||||||
margin: 10px 5px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.secondary {
|
|
||||||
background: linear-gradient(45deg, #6c757d, #495057);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.secondary:hover {
|
|
||||||
box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.danger {
|
|
||||||
background: linear-gradient(45deg, #dc3545, #c82333);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.danger:hover {
|
|
||||||
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-card {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin: 0 auto 15px;
|
|
||||||
display: block;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-about {
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-pubkey {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-output {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-entry {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-timestamp {
|
|
||||||
color: #ccc;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.methods-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.methods-list li {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
margin: 10px 0;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
|
||||||
<h1>🔐 NOSTR_LOGIN_LITE - All Login Methods Test</h1>
|
|
||||||
|
|
||||||
<div id="status" class="status logged-out">
|
<div id="login-section">
|
||||||
⏳ Initializing NOSTR_LOGIN_LITE...
|
<!-- Login UI if needed -->
|
||||||
</div>
|
</div>
|
||||||
|
<div id="profile-section">
|
||||||
<div id="login-section">
|
<img id="profile-picture">
|
||||||
<button id="launch-modal" class="button">🚀 Launch Login Modal</button>
|
<div id="profile-pubkey"></div>
|
||||||
<p style="font-size: 14px; opacity: 0.8; margin-top: 10px;">
|
<div id="profile-name"></div>
|
||||||
Click to open the NOSTR_LOGIN_LITE modal and test all available login methods:
|
<div id="profile-about"></div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="info-section">
|
|
||||||
<h3>Available Login Methods</h3>
|
|
||||||
<ul class="methods-list">
|
|
||||||
<li><strong>Browser Extension:</strong> Alby, nos2x, etc. (if installed)</li>
|
|
||||||
<li><strong>Local Key:</strong> Generate new keys or import existing private key/nsec</li>
|
|
||||||
<li><strong>Read Only:</strong> Access public content without authentication</li>
|
|
||||||
<li><strong>Nostr Connect (NIP-46):</strong> Connect to remote signing services</li>
|
|
||||||
<li><strong>DM/OTP:</strong> Secure local accounts with one-time passwords</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="profile-section" style="display: none;">
|
|
||||||
<div class="profile-card">
|
|
||||||
<div class="profile-header">
|
|
||||||
<img id="profile-picture" class="profile-avatar" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iNDAiIGN5PSI0MCIgcj0iNDAiIGZpbGw9IiNmNmY2ZjYiLz4KPHBhdGggZD0iTTQwIDQ1QzQ1IDQ1IDUwIDQyIDUwIDQwUzQ1IDQxIDQwIDQ1WiIgc3Ryb2tlPSIjYmJkIiBzdHJva2Utd2lkdGg9IjIiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgo8Y2lyY2xlIGN4PSIzNSIgY3k9IjM1IiByPSIyIiBmaWxsPSIjYmJkIi8+CjxjaXJjbGUgY3g9IjQ1IiBjeT0iMzUiIHI9IjIiIGZpbGw9IiNiYmQiLz4KPGRlZnM+Cj08L2RlZnM+Cjwvc3ZnPgo=" alt="Profile Picture">
|
|
||||||
<div id="profile-name" class="profile-name">Loading...</div>
|
|
||||||
<div id="profile-about" class="profile-about"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Public Key:</strong>
|
|
||||||
<div id="profile-pubkey" class="profile-pubkey"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="refresh-profile" class="button secondary">🔄 Refresh Profile</button>
|
|
||||||
<button id="logout" class="button danger">🚪 Logout</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="console-output" id="console-display">
|
|
||||||
<div class="console-entry">
|
|
||||||
<span class="console-timestamp">[Console]</span> Ready for testing
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load the official nostr-tools bundle first -->
|
<!-- Load the official nostr-tools bundle first -->
|
||||||
<script src="../lite/nostr.bundle.js"></script>
|
<script src="../lite/nostr.bundle.js"></script>
|
||||||
|
|
||||||
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
|
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
|
||||||
<script src="../lite/nostr-lite.js"></script>
|
<script src="../lite/nostr-lite.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Console logging helper
|
|
||||||
function log(level, message) {
|
|
||||||
const consoleDiv = document.getElementById('console-display');
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
entry.className = 'console-entry';
|
|
||||||
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
const prefix = level === 'ERROR' ? '[ERROR]' :
|
|
||||||
level === 'SUCCESS' ? '[SUCCESS]' :
|
|
||||||
level === 'WARNING' ? '[WARNING]' : '[INFO]';
|
|
||||||
|
|
||||||
entry.innerHTML = `<span class="console-timestamp">[${timestamp}] ${prefix}</span> ${message}`;
|
|
||||||
consoleDiv.appendChild(entry);
|
|
||||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global variables
|
// Global variables
|
||||||
let nlLite = null;
|
let nlLite = null;
|
||||||
@@ -260,11 +45,11 @@
|
|||||||
|
|
||||||
// Initialize NOSTR_LOGIN_LITE
|
// Initialize NOSTR_LOGIN_LITE
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
log('INFO', 'Initializing NOSTR_LOGIN_LITE...');
|
// console.log('INFO', 'Initializing NOSTR_LOGIN_LITE...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.NOSTR_LOGIN_LITE.init({
|
await window.NOSTR_LOGIN_LITE.init({
|
||||||
theme: 'dark',
|
theme: 'default',
|
||||||
darkMode: false,
|
darkMode: false,
|
||||||
relays: [relayUrl, 'wss://relay.damus.io'],
|
relays: [relayUrl, 'wss://relay.damus.io'],
|
||||||
methods: {
|
methods: {
|
||||||
@@ -300,137 +85,67 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
nlLite = window.NOSTR_LOGIN_LITE;
|
nlLite = window.NOSTR_LOGIN_LITE;
|
||||||
log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
|
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
|
||||||
document.getElementById('status').innerHTML = '✅ Ready - Click "Launch Login Modal" to test all login methods';
|
|
||||||
document.getElementById('status').className = 'status logged-in';
|
window.addEventListener('nlMethodSelected', handleAuthEvent);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('ERROR', `Initialization failed: ${error.message}`);
|
console.log('ERROR', `Initialization failed: ${error.message}`);
|
||||||
document.getElementById('status').innerHTML = '❌ Failed to initialize NOSTR_LOGIN_LITE';
|
|
||||||
document.getElementById('status').className = 'status logged-out';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch the login modal
|
|
||||||
async function launchLoginModal() {
|
|
||||||
log('INFO', 'Launching NOSTR_LOGIN_LITE modal...');
|
|
||||||
document.getElementById('status').innerHTML = '🔄 Opening login modal...';
|
|
||||||
document.getElementById('status').className = 'status loading';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Launch the modal
|
|
||||||
await nlLite.launch('login');
|
|
||||||
log('SUCCESS', 'Login modal launched successfully');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log('ERROR', `Failed to launch modal: ${error.message}`);
|
|
||||||
document.getElementById('status').innerHTML = '❌ Failed to launch modal';
|
|
||||||
document.getElementById('status').className = 'status logged-out';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle authentication events
|
|
||||||
function handleAuthEvent(event) {
|
function handleAuthEvent(event) {
|
||||||
const { type, pubkey, method, error } = event.detail;
|
const {pubkey, method, error } = event.detail;
|
||||||
|
console.log('INFO', `Auth event received: method=${method}`);
|
||||||
log('INFO', `Auth event received: type=${type}, method=${method}`);
|
|
||||||
|
if (method && pubkey) {
|
||||||
if (type === 'login' && pubkey) {
|
|
||||||
userPubkey = pubkey;
|
userPubkey = pubkey;
|
||||||
log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
|
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
|
||||||
|
|
||||||
document.getElementById('status').innerHTML = `✅ Logged in via ${method}!`;
|
|
||||||
document.getElementById('status').className = 'status logged-in';
|
|
||||||
|
|
||||||
// Show profile section
|
|
||||||
document.getElementById('login-section').style.display = 'none';
|
|
||||||
document.getElementById('profile-section').style.display = 'block';
|
|
||||||
|
|
||||||
// Load profile
|
|
||||||
loadUserProfile();
|
loadUserProfile();
|
||||||
|
|
||||||
} else if (type === 'logout') {
|
|
||||||
log('INFO', 'User logged out');
|
|
||||||
userPubkey = null;
|
|
||||||
|
|
||||||
document.getElementById('status').innerHTML = '✅ Ready - Click "Launch Login Modal" to test all login methods';
|
|
||||||
document.getElementById('status').className = 'status logged-in';
|
|
||||||
|
|
||||||
// Show login section
|
|
||||||
document.getElementById('login-section').style.display = 'block';
|
|
||||||
document.getElementById('profile-section').style.display = 'none';
|
|
||||||
|
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
log('ERROR', `Authentication error: ${error}`);
|
console.log('ERROR', `Authentication error: ${error}`);
|
||||||
document.getElementById('status').innerHTML = '❌ Authentication failed';
|
|
||||||
document.getElementById('status').className = 'status logged-out';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user profile
|
// Load user profile using nostr-tools pool
|
||||||
async function loadUserProfile() {
|
async function loadUserProfile() {
|
||||||
if (!userPubkey) return;
|
if (!userPubkey) return;
|
||||||
|
|
||||||
log('INFO', `Loading profile for: ${userPubkey}`);
|
console.log('INFO', `Loading profile for: ${userPubkey}`);
|
||||||
document.getElementById('profile-name').textContent = 'Loading profile...';
|
document.getElementById('profile-name').textContent = 'Loading profile...';
|
||||||
document.getElementById('profile-pubkey').textContent = userPubkey;
|
document.getElementById('profile-pubkey').textContent = userPubkey;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simple WebSocket connection to get profile
|
// Create a SimplePool instance
|
||||||
const ws = new WebSocket(relayUrl);
|
const pool = new window.NostrTools.SimplePool();
|
||||||
|
const relays = [relayUrl, 'wss://relay.laantungir.net'];
|
||||||
|
|
||||||
|
// Get profile event (kind 0) for the user using querySync
|
||||||
|
const events = await pool.querySync(relays, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [userPubkey],
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
ws.onopen = () => {
|
pool.close(relays); // Clean up connections
|
||||||
log('SUCCESS', 'WebSocket connected, requesting profile...');
|
|
||||||
const req = JSON.stringify([
|
|
||||||
'REQ',
|
|
||||||
'profile',
|
|
||||||
{
|
|
||||||
kinds: [0],
|
|
||||||
authors: [userPubkey],
|
|
||||||
limit: 1
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
ws.send(req);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
if (events.length > 0) {
|
||||||
try {
|
console.log('SUCCESS', 'Profile event received');
|
||||||
const message = JSON.parse(event.data);
|
const profile = JSON.parse(events[0].content);
|
||||||
const [type, subscriptionId, eventData] = message;
|
displayProfile(profile);
|
||||||
|
} else {
|
||||||
if (type === 'EVENT' && eventData && eventData.kind === 0) {
|
console.log('INFO', 'No profile found');
|
||||||
log('SUCCESS', 'Profile event received');
|
document.getElementById('profile-name').textContent = 'No profile found';
|
||||||
const profile = JSON.parse(eventData.content);
|
document.getElementById('profile-about').textContent = 'User has not set up a profile yet.';
|
||||||
displayProfile(profile);
|
}
|
||||||
ws.close();
|
|
||||||
} else if (type === 'EOSE') {
|
|
||||||
log('INFO', 'End of subscription');
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
log('ERROR', `Failed to parse WebSocket message: ${parseError.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
log('ERROR', `WebSocket error: ${error.message || 'Connection failed'}`);
|
|
||||||
document.getElementById('profile-name').textContent = 'Error loading profile';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (ws.readyState !== WebSocket.CLOSED) {
|
|
||||||
ws.close();
|
|
||||||
if (document.getElementById('profile-name').textContent === 'Loading profile...') {
|
|
||||||
document.getElementById('profile-name').textContent = 'Profile timeout';
|
|
||||||
document.getElementById('profile-about').textContent = 'Could not load profile from relay.';
|
|
||||||
log('WARNING', 'Profile request timed out');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('ERROR', `Profile loading failed: ${error.message}`);
|
console.log('ERROR', `Profile loading failed: ${error.message}`);
|
||||||
document.getElementById('profile-name').textContent = 'Error loading profile';
|
document.getElementById('profile-name').textContent = 'Error loading profile';
|
||||||
document.getElementById('profile-about').textContent = error.message;
|
document.getElementById('profile-about').textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -449,33 +164,26 @@
|
|||||||
document.getElementById('profile-picture').src = picture;
|
document.getElementById('profile-picture').src = picture;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('SUCCESS', `Profile displayed: ${name}`);
|
console.log('SUCCESS', `Profile displayed: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout function
|
// Logout function
|
||||||
async function logout() {
|
async function logout() {
|
||||||
log('INFO', 'Logging out...');
|
console.log('INFO', 'Logging out...');
|
||||||
try {
|
try {
|
||||||
await nlLite.logout();
|
await nlLite.logout();
|
||||||
log('SUCCESS', 'Logged out successfully');
|
console.log('SUCCESS', 'Logged out successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('ERROR', `Logout failed: ${error.message}`);
|
console.log('ERROR', `Logout failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Button event listeners
|
|
||||||
document.getElementById('launch-modal').addEventListener('click', launchLoginModal);
|
|
||||||
document.getElementById('refresh-profile').addEventListener('click', loadUserProfile);
|
|
||||||
document.getElementById('logout').addEventListener('click', logout);
|
|
||||||
|
|
||||||
// Listen for authentication events
|
|
||||||
window.addEventListener('nlAuth', handleAuthEvent);
|
|
||||||
|
|
||||||
// Initialize the app
|
// Initialize the app
|
||||||
setTimeout(initializeApp, 100);
|
setTimeout(initializeApp, 100);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>🔐 NOSTR_LOGIN_LITE - Full Modal Login Demo</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
background: linear-gradient(45deg, #fff, #007bff);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: linear-gradient(45deg, #007bff, #0056b3);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 15px 30px;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: inline-block;
|
|
||||||
margin: 10px 5px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.secondary {
|
|
||||||
background: linear-gradient(45deg, #6c757d, #495057);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.secondary:hover {
|
|
||||||
box-shadow: 0 4px 15px rgba(108, 117, 125, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success { background: rgba(76, 175, 80, 0.2); color: #81c784; }
|
|
||||||
.status.error { background: rgba(244, 67, 54, 0.2); color: #ef5350; }
|
|
||||||
.status.warning { background: rgba(255, 193, 7, 0.2); color: #ffd54f; }
|
|
||||||
.status.info { background: rgba(33, 150, 243, 0.2); color: #64b5f6; }
|
|
||||||
|
|
||||||
.console-output {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-entry {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-timestamp {
|
|
||||||
color: #ccc;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item .icon {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item h3 {
|
|
||||||
margin: 0 0 5px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item p {
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0.8;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🔐 NOSTR_LOGIN_LITE Full Modal Login Demo</h1>
|
|
||||||
|
|
||||||
<div class="demo-section">
|
|
||||||
<h2>📚 Available Login Methods</h2>
|
|
||||||
<p>This demo showcases all login methods provided by NOSTR_LOGIN_LITE:</p>
|
|
||||||
|
|
||||||
<div class="feature-list">
|
|
||||||
<div class="feature-item">
|
|
||||||
<div class="icon">📱</div>
|
|
||||||
<h3>Extension Login</h3>
|
|
||||||
<p>Use browser extensions like Alby, nos2x, or other Nostr-compatible extensions</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-item">
|
|
||||||
<div class="icon">💾</div>
|
|
||||||
<h3>Local Account</h3>
|
|
||||||
<p>Create and manage local Nostr keypairs stored in browser storage</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-item">
|
|
||||||
<div class="icon">👁️</div>
|
|
||||||
<h3>Read-Only Account</h3>
|
|
||||||
<p>Access public content without authentication (limited functionality)</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-item">
|
|
||||||
<div class="icon">🔗</div>
|
|
||||||
<h3>NIP-46 Remote</h3>
|
|
||||||
<p>Connect to remote signers for secure key management</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-item">
|
|
||||||
<div class="icon">🔐</div>
|
|
||||||
<h3>OTP Backup</h3>
|
|
||||||
<p>Secure local accounts with time-based one-time passwords</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Library Status -->
|
|
||||||
<div class="demo-section">
|
|
||||||
<h2>⚙️ Library Status</h2>
|
|
||||||
<div id="dep-status" class="status info">Loading nostr-tools...</div>
|
|
||||||
<div id="lib-status" class="status info">Loading NOSTR_LOGIN_LITE...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Authentication -->
|
|
||||||
<div class="demo-section">
|
|
||||||
<h2>🎯 Launch Full Login Modal</h2>
|
|
||||||
<p>Click the button below to launch the complete authentication modal with all available login options:</p>
|
|
||||||
<button id="launch-auth" class="button">🚀 Launch Authentication Modal</button>
|
|
||||||
<button onclick="location.reload()" class="button secondary">🔄 Reload Page</button>
|
|
||||||
<div id="auth-status" class="status" style="margin-top: 15px;">Ready to authenticate...</div>
|
|
||||||
<div style="font-size: 14px; opacity: 0.8; margin-top: 10px;">
|
|
||||||
The modal will show all available login methods based on your browser setup and library configuration.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Info Display (shown after login) -->
|
|
||||||
<div id="user-info" class="demo-section" style="display: none;">
|
|
||||||
<h2>👤 User Information</h2>
|
|
||||||
<div id="user-details">
|
|
||||||
<strong>Public Key:</strong> <span id="user-pubkey">Loading...</span><br>
|
|
||||||
<strong>Login Method:</strong> <span id="user-method">Loading...</span><br>
|
|
||||||
<strong>Account Type:</strong> <span id="user-type">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Console Log -->
|
|
||||||
<div class="console-output" id="console-display">
|
|
||||||
<div class="console-entry">
|
|
||||||
<span class="console-timestamp">[Demo]</span> Modal Login Demo initialized
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Load the official nostr-tools bundle first -->
|
|
||||||
<script src="../lite/nostr.bundle.js"></script>
|
|
||||||
|
|
||||||
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
|
|
||||||
<script src="../lite/nostr-lite.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Console logging helper
|
|
||||||
function addConsoleEntry(message, type = 'info') {
|
|
||||||
const consoleDiv = document.getElementById('console-display');
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
entry.className = 'console-entry';
|
|
||||||
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
const prefix = type === 'error' ? '[ERROR]' :
|
|
||||||
type === 'success' ? '[SUCCESS]' :
|
|
||||||
type === 'warning' ? '[WARNING]' : '[INFO]';
|
|
||||||
|
|
||||||
entry.innerHTML = `<span class="console-timestamp">[${timestamp}] ${prefix}</span> ${message}`;
|
|
||||||
consoleDiv.appendChild(entry);
|
|
||||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let authInitialized = false;
|
|
||||||
|
|
||||||
// Event listeners for authentication events
|
|
||||||
window.addEventListener('nlAuth', (event) => {
|
|
||||||
addConsoleEntry(`Authentication event: ${event.detail.type}`, 'success');
|
|
||||||
if (event.detail.pubkey) {
|
|
||||||
addConsoleEntry(`User authenticated: ${event.detail.pubkey}`, 'success');
|
|
||||||
displayUserInfo(event.detail);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('nlLogout', (event) => {
|
|
||||||
addConsoleEntry('User logged out', 'warning');
|
|
||||||
hideUserInfo();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('nlAuthUrl', (event) => {
|
|
||||||
addConsoleEntry(`Auth URL generated: ${event.detail.url}`, 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('nlError', (event) => {
|
|
||||||
addConsoleEntry(`Authentication error: ${event.detail.message}`, 'error');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Library load checking with retry
|
|
||||||
function checkLibraryLoaded() {
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 50; // 5 seconds
|
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
if (window.NostrTools) {
|
|
||||||
document.getElementById('dep-status').textContent = '✓ nostr-tools loaded successfully!';
|
|
||||||
document.getElementById('dep-status').className = 'status success';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.NOSTR_LOGIN_LITE) {
|
|
||||||
document.getElementById('lib-status').textContent = '✓ NOSTR_LOGIN_LITE loaded successfully!';
|
|
||||||
document.getElementById('lib-status').className = 'status success';
|
|
||||||
enableModalLaunch();
|
|
||||||
} else {
|
|
||||||
attempts++;
|
|
||||||
if (attempts < maxAttempts) {
|
|
||||||
setTimeout(check, 100);
|
|
||||||
} else {
|
|
||||||
document.getElementById('lib-status').textContent = '✗ Failed to load NOSTR_LOGIN_LITE';
|
|
||||||
document.getElementById('lib-status').className = 'status error';
|
|
||||||
addConsoleEntry('Bundle might have JavaScript errors - check browser console', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable the modal launch button
|
|
||||||
function enableModalLaunch() {
|
|
||||||
const launchBtn = document.getElementById('launch-auth');
|
|
||||||
launchBtn.disabled = false;
|
|
||||||
launchBtn.textContent = '🚀 Launch Authentication Modal';
|
|
||||||
addConsoleEntry('Full modal authentication ready', 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch authentication modal
|
|
||||||
async function launchAuthModal() {
|
|
||||||
const launchBtn = document.getElementById('launch-auth');
|
|
||||||
const status = document.getElementById('auth-status');
|
|
||||||
|
|
||||||
try {
|
|
||||||
status.textContent = '🔄 Initializing authentication...';
|
|
||||||
status.className = 'status warning';
|
|
||||||
launchBtn.disabled = true;
|
|
||||||
|
|
||||||
// Initialize NOSTR_LOGIN_LITE with all methods enabled
|
|
||||||
const options = {
|
|
||||||
theme: 'dark',
|
|
||||||
darkMode: false,
|
|
||||||
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'],
|
|
||||||
methods: {
|
|
||||||
extension: true,
|
|
||||||
local: true,
|
|
||||||
readonly: true,
|
|
||||||
remote: true,
|
|
||||||
otp: true
|
|
||||||
},
|
|
||||||
debug: true
|
|
||||||
};
|
|
||||||
|
|
||||||
addConsoleEntry('Initializing NOSTR_LOGIN_LITE with full configuration', 'info');
|
|
||||||
|
|
||||||
if (!authInitialized) {
|
|
||||||
await window.NOSTR_LOGIN_LITE.init(options);
|
|
||||||
authInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
addConsoleEntry('Launching full authentication modal', 'info');
|
|
||||||
status.textContent = '🎨 Opening authentication modal...';
|
|
||||||
|
|
||||||
// Launch the modal - this will show all available methods
|
|
||||||
window.NOSTR_LOGIN_LITE.launch('login');
|
|
||||||
|
|
||||||
status.textContent = '✅ Authentication modal launched!';
|
|
||||||
status.className = 'status success';
|
|
||||||
|
|
||||||
addConsoleEntry('Modal launched successfully - all login methods available', 'success');
|
|
||||||
|
|
||||||
// Re-enable button after a delay
|
|
||||||
setTimeout(() => {
|
|
||||||
launchBtn.disabled = false;
|
|
||||||
launchBtn.textContent = '🔄 Launch Again';
|
|
||||||
status.textContent = 'Ready to launch modal again...';
|
|
||||||
status.className = 'status info';
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
addConsoleEntry(`Modal launch failed: ${error.message}`, 'error');
|
|
||||||
status.textContent = '❌ Failed to launch modal';
|
|
||||||
status.className = 'status error';
|
|
||||||
launchBtn.disabled = false;
|
|
||||||
launchBtn.textContent = '🚀 Try Again';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display user information after successful authentication
|
|
||||||
function displayUserInfo(details) {
|
|
||||||
document.getElementById('user-info').style.display = 'block';
|
|
||||||
document.getElementById('user-pubkey').textContent = details.pubkey || 'Unknown';
|
|
||||||
document.getElementById('user-method').textContent = details.method || 'Unknown';
|
|
||||||
document.getElementById('user-type').textContent = getAccountType(details.method);
|
|
||||||
|
|
||||||
const status = document.getElementById('auth-status');
|
|
||||||
status.textContent = '✅ Successfully authenticated!';
|
|
||||||
status.className = 'status success';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide user info on logout
|
|
||||||
function hideUserInfo() {
|
|
||||||
document.getElementById('user-info').style.display = 'none';
|
|
||||||
|
|
||||||
const status = document.getElementById('auth-status');
|
|
||||||
status.textContent = '👋 User logged out';
|
|
||||||
status.className = 'status warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get readable account type
|
|
||||||
function getAccountType(method) {
|
|
||||||
const types = {
|
|
||||||
extension: 'Browser Extension',
|
|
||||||
local: 'Local Account',
|
|
||||||
readonly: 'Read-Only Account',
|
|
||||||
remote: 'NIP-46 Remote',
|
|
||||||
otp: 'OTP Secured Local'
|
|
||||||
};
|
|
||||||
return types[method] || 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize everything when DOM loads
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
addConsoleEntry('Demo page loaded, initializing libraries...', 'info');
|
|
||||||
|
|
||||||
// Check if libraries are loaded
|
|
||||||
checkLibraryLoaded();
|
|
||||||
|
|
||||||
// Set up the modal launch button
|
|
||||||
document.getElementById('launch-auth').addEventListener('click', launchAuthModal);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('NOSTR_LOGIN_LITE Modal Demo loaded');
|
|
||||||
console.log('Available login methods will be shown in modal');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
75
examples/modal.html
Normal file
75
examples/modal.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Embedded NOSTR_LOGIN_LITE</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-container {
|
||||||
|
/* No styling - let embedded modal blend seamlessly */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="../lite/nostr.bundle.js"></script>
|
||||||
|
<script src="../lite/nostr-lite.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await window.NOSTR_LOGIN_LITE.init({
|
||||||
|
theme: 'dark',
|
||||||
|
methods: {
|
||||||
|
extension: true,
|
||||||
|
local: true,
|
||||||
|
readonly: true,
|
||||||
|
connect: true,
|
||||||
|
remote: true,
|
||||||
|
otp: true
|
||||||
|
},
|
||||||
|
floatingTab: {
|
||||||
|
enabled: true,
|
||||||
|
hPosition: 0.7, // 0.0-1.0 or '95%' from left
|
||||||
|
vPosition: 0.5, // 0.0-1.0 or '50%' from top
|
||||||
|
appearance: {
|
||||||
|
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
|
||||||
|
theme: 'auto', // 'auto' follows main theme
|
||||||
|
icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
|
||||||
|
text: 'Login'
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
hideWhenAuthenticated: false,
|
||||||
|
showUserInfo: true,
|
||||||
|
autoSlide: true
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down'
|
||||||
|
}
|
||||||
|
|
||||||
|
}});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* Two-file architecture:
|
* Two-file architecture:
|
||||||
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
||||||
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
|
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
|
||||||
* Generated on: 2025-09-14T17:19:15.753Z
|
* Generated on: 2025-09-14T20:06:51.995Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Verify dependencies are loaded
|
// Verify dependencies are loaded
|
||||||
@@ -550,15 +550,15 @@ const THEME_CSS = {
|
|||||||
|
|
||||||
/* Floating Tab Variables (8) */
|
/* Floating Tab Variables (8) */
|
||||||
--nl-tab-bg-logged-out: #ffffff;
|
--nl-tab-bg-logged-out: #ffffff;
|
||||||
--nl-tab-bg-logged-in: #000000;
|
--nl-tab-bg-logged-in: #ffffff;
|
||||||
--nl-tab-bg-opacity-logged-out: 0.9;
|
--nl-tab-bg-opacity-logged-out: 0.9;
|
||||||
--nl-tab-bg-opacity-logged-in: 0.8;
|
--nl-tab-bg-opacity-logged-in: 0.2;
|
||||||
--nl-tab-color-logged-out: #000000;
|
--nl-tab-color-logged-out: #000000;
|
||||||
--nl-tab-color-logged-in: #ffffff;
|
--nl-tab-color-logged-in: #ffffff;
|
||||||
--nl-tab-border-logged-out: #000000;
|
--nl-tab-border-logged-out: #000000;
|
||||||
--nl-tab-border-logged-in: #ff0000;
|
--nl-tab-border-logged-in: #ff0000;
|
||||||
--nl-tab-border-opacity-logged-out: 1.0;
|
--nl-tab-border-opacity-logged-out: 1.0;
|
||||||
--nl-tab-border-opacity-logged-in: 0.9;
|
--nl-tab-border-opacity-logged-in: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base component styles using simplified variables */
|
/* Base component styles using simplified variables */
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
# 🏰 NIP-46 Remote Signer (Bunker) Test Setup
|
|
||||||
|
|
||||||
This directory contains a complete NIP-46 remote signing setup for testing NOSTR_LOGIN_LITE.
|
|
||||||
|
|
||||||
## 🔧 Setup Overview
|
|
||||||
|
|
||||||
**Bunker**: A remote signer daemon that holds your private keys securely
|
|
||||||
**Client**: Browser client that connects to the bunker to request signatures
|
|
||||||
**NOSTR_LOGIN_LITE**: Connects to bunker for remote signing capability
|
|
||||||
|
|
||||||
## 🔑 Generated Keys
|
|
||||||
|
|
||||||
```
|
|
||||||
Bunker Secret Key: a33767c3bd05bda47880119d6665b79e6f0eecdf8d025966b0b59a9366379d01
|
|
||||||
Bunker Public Key: 7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Testing NIP-46 Remote Signing
|
|
||||||
|
|
||||||
### Step 1: Start the Bunker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Open a new terminal and run:
|
|
||||||
./nip46-test/start-bunker.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
You'll see output like:
|
|
||||||
```
|
|
||||||
🔐 Starting NIP-46 Bunker Remote Signer...
|
|
||||||
==============================================
|
|
||||||
Bunker Public Key: 7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90
|
|
||||||
Secret key is securely held by bunker
|
|
||||||
|
|
||||||
🚀 Starting bunker daemon...
|
|
||||||
{"time":"202X-XX-XXTXX:XX:XX.XXXZ","level":"info","msg":"starting bunker on ws://localhost:8080"}
|
|
||||||
{"time":"202X-XX-XXTXX:XX:XX.XXXZ","level":"info","msg":"bunker ready to handle NIP-46 requests"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Test with NOSTR_LOGIN_LITE
|
|
||||||
|
|
||||||
Navigate to:
|
|
||||||
```
|
|
||||||
http://localhost:8000/examples/modal-login-demo.html
|
|
||||||
```
|
|
||||||
|
|
||||||
Click "🚀 Launch Authentication Modal" and select **"NIP-46 Remote"** option.
|
|
||||||
|
|
||||||
The browser will connect to the bunker running on `ws://localhost:8080` and request signatures remotely.
|
|
||||||
|
|
||||||
## 🔄 How NIP-46 Works
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser (NOSTR_LOGIN_LITE) → WebSocket → Bunker (NAK on localhost:8080)
|
|
||||||
↓ ↓
|
|
||||||
Requests signature Holds private key
|
|
||||||
↓ ↓
|
|
||||||
Receives signed event Signs & returns result
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Files in this Directory
|
|
||||||
|
|
||||||
- `start-bunker.sh` - Script to start the remote signer daemon
|
|
||||||
- `bunker-config.js` - Configuration for NOSTR_LOGIN_LITE
|
|
||||||
- `README.md` - This documentation
|
|
||||||
|
|
||||||
## 🧪 Testing Scenarios
|
|
||||||
|
|
||||||
### ✅ Successful Connection
|
|
||||||
- Bunker runs on localhost:8080
|
|
||||||
- Browser connects and requests pubkey
|
|
||||||
- Bunker responds with `7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90`
|
|
||||||
|
|
||||||
### 🔧 Signature Requests
|
|
||||||
- Browser sends event to sign
|
|
||||||
- Bunker signs with private key
|
|
||||||
- Signed event returned to browser
|
|
||||||
- Browser publishes signed event to relay
|
|
||||||
|
|
||||||
### 🐛 Debug Issues
|
|
||||||
- Check bunker logs for connection errors
|
|
||||||
- Verify WebSocket connection in browser dev tools
|
|
||||||
- Look for NIP-46 protocol errors
|
|
||||||
|
|
||||||
## 📝 NOSTR_LOGIN_LITE Configuration
|
|
||||||
|
|
||||||
In your app, configure remote signing like this:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const nip46Config = {
|
|
||||||
type: "nip46",
|
|
||||||
bunker: {
|
|
||||||
pubkey: "7566048aa9df5b36428f2ce364797f7ac6f6d4a17ee566f0cd3fefcf35146b90",
|
|
||||||
url: "ws://your-bunker-server:8080" // Production URL
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await window.NOSTR_LOGIN_LITE.init(nip46Config);
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ Production Notes
|
|
||||||
|
|
||||||
- **This setup uses localhost** - replace with real server URL in production
|
|
||||||
- **Private key is shown for testing** - production bunkers should be secured
|
|
||||||
- **WebSocket URL should be secure** (wss://) in production
|
|
||||||
- **Consider authentication** for your bunker to prevent unauthorized access
|
|
||||||
|
|
||||||
## 🎯 Common Testing Commands
|
|
||||||
|
|
||||||
### Check NAK version
|
|
||||||
```bash
|
|
||||||
nak --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generate new keys (if needed)
|
|
||||||
```bash
|
|
||||||
nak key generate # Secret key
|
|
||||||
echo "your_secret_key_here" | nak key public # Public key
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual bunker test
|
|
||||||
```bash
|
|
||||||
nak bunker --sec "your_secret_key" --port 8080 --relay "wss://relay.damus.io"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
**Bunker won't start:**
|
|
||||||
- Check if port 8080 is free
|
|
||||||
- Verify NAK is installed correctly
|
|
||||||
|
|
||||||
**Browser can't connect:**
|
|
||||||
- Check firewall settings
|
|
||||||
- Verify bunker is running (`ps aux | grep nak`)
|
|
||||||
- Check browser console for WebSocket errors
|
|
||||||
|
|
||||||
**Signing fails:**
|
|
||||||
- Verify keys are correct
|
|
||||||
- Check bunker logs for errors
|
|
||||||
- Ensure event format is valid
|
|
||||||
370
themes/README.md
Normal file
370
themes/README.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# NOSTR_LOGIN_LITE Theme System
|
||||||
|
|
||||||
|
A comprehensive theming system supporting CSS custom properties, JSON metadata, and runtime theme switching.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The theme system consists of:
|
||||||
|
|
||||||
|
- **CSS Custom Properties**: Dynamic styling variables
|
||||||
|
- **JSON Metadata**: Theme descriptions and configurations
|
||||||
|
- **Theme Manager**: Runtime loading and switching
|
||||||
|
- **Directory Organization**: Structured theme packages
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
themes/
|
||||||
|
├── README.md # This documentation
|
||||||
|
├── theme-manager.js # Theme management system
|
||||||
|
├── default/ # Default monospace theme
|
||||||
|
│ ├── theme.json # Theme metadata
|
||||||
|
│ ├── theme.css # CSS custom properties
|
||||||
|
│ └── assets/ # Theme assets (fonts, images)
|
||||||
|
├── dark/ # Dark cyberpunk theme
|
||||||
|
│ ├── theme.json
|
||||||
|
│ ├── theme.css
|
||||||
|
│ └── assets/
|
||||||
|
└── community/ # Community contributed themes
|
||||||
|
└── [theme-name]/
|
||||||
|
├── theme.json
|
||||||
|
├── theme.css
|
||||||
|
└── assets/
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Custom Properties
|
||||||
|
|
||||||
|
All themes use standardized CSS custom properties with the `--nl-` prefix:
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
- `--nl-primary-color`: Main text/border color
|
||||||
|
- `--nl-secondary-color`: Background color
|
||||||
|
- `--nl-accent-color`: Hover/active accent color
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- `--nl-font-family`: Base font family
|
||||||
|
- `--nl-font-size-base`: Base font size (14px)
|
||||||
|
- `--nl-font-size-title`: Title font size (24px)
|
||||||
|
- `--nl-font-size-heading`: Heading font size (18px)
|
||||||
|
- `--nl-font-size-button`: Button font size (16px)
|
||||||
|
- `--nl-font-weight-normal`: Normal weight (400)
|
||||||
|
- `--nl-font-weight-medium`: Medium weight (500)
|
||||||
|
- `--nl-font-weight-bold`: Bold weight (600)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- `--nl-border-radius`: Border radius (15px)
|
||||||
|
- `--nl-border-width`: Border thickness (3px)
|
||||||
|
- `--nl-border-style`: Border style (solid)
|
||||||
|
- `--nl-padding-button`: Button padding (12px 16px)
|
||||||
|
- `--nl-padding-container`: Container padding (20px 24px)
|
||||||
|
|
||||||
|
### Effects
|
||||||
|
- `--nl-transition-duration`: Animation duration (0.2s)
|
||||||
|
- `--nl-transition-easing`: Animation easing (ease)
|
||||||
|
- `--nl-shadow`: Box shadow effects
|
||||||
|
- `--nl-backdrop-filter`: Backdrop filter effects
|
||||||
|
|
||||||
|
### Component States
|
||||||
|
- `--nl-button-bg`: Button background
|
||||||
|
- `--nl-button-color`: Button text color
|
||||||
|
- `--nl-button-border`: Button border
|
||||||
|
- `--nl-button-hover-border-color`: Button hover border
|
||||||
|
- `--nl-button-active-bg`: Button active background
|
||||||
|
- `--nl-button-active-color`: Button active text
|
||||||
|
|
||||||
|
## Theme Metadata (theme.json)
|
||||||
|
|
||||||
|
Each theme must include a `theme.json` file with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Theme Display Name",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Author Name/Email",
|
||||||
|
"description": "Theme description",
|
||||||
|
"preview": "preview.png",
|
||||||
|
"compatibility": "1.0+",
|
||||||
|
"license": "MIT",
|
||||||
|
"variables": {
|
||||||
|
"--nl-primary-color": "#000000",
|
||||||
|
"--nl-secondary-color": "#ffffff",
|
||||||
|
"--nl-accent-color": "#ff0000"
|
||||||
|
},
|
||||||
|
"assets": ["fonts/", "images/"],
|
||||||
|
"tags": ["monospace", "dark", "accessibility"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
- `name`: Human-readable theme name
|
||||||
|
- `version`: Semantic version number
|
||||||
|
- `variables`: CSS custom property values
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
- `author`: Theme creator information
|
||||||
|
- `description`: Theme description
|
||||||
|
- `preview`: Preview image filename
|
||||||
|
- `compatibility`: Minimum library version
|
||||||
|
- `license`: License identifier (MIT, GPL, etc.)
|
||||||
|
- `assets`: Additional asset directories
|
||||||
|
- `tags`: Theme categorization tags
|
||||||
|
|
||||||
|
## Creating a New Theme
|
||||||
|
|
||||||
|
### 1. Create Theme Directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir themes/my-theme
|
||||||
|
cd themes/my-theme
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create theme.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Custom Theme",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"description": "A custom theme for NOSTR_LOGIN_LITE",
|
||||||
|
"variables": {
|
||||||
|
"--nl-primary-color": "#your-color",
|
||||||
|
"--nl-secondary-color": "#your-bg-color",
|
||||||
|
"--nl-accent-color": "#your-accent-color",
|
||||||
|
"--nl-font-family": "\"Your Font\", monospace"
|
||||||
|
},
|
||||||
|
"tags": ["custom"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create theme.css
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--nl-primary-color: #your-color;
|
||||||
|
--nl-secondary-color: #your-bg-color;
|
||||||
|
--nl-accent-color: #your-accent-color;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--nl-font-family: "Your Font", monospace;
|
||||||
|
|
||||||
|
/* Layout - inherit defaults or customize */
|
||||||
|
--nl-border-radius: 15px;
|
||||||
|
--nl-border-width: 3px;
|
||||||
|
|
||||||
|
/* Add custom variables */
|
||||||
|
--nl-custom-shadow: 0 0 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: Custom component styles */
|
||||||
|
.nl-button {
|
||||||
|
/* Theme-specific enhancements */
|
||||||
|
box-shadow: var(--nl-custom-shadow);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add Assets (Optional)
|
||||||
|
|
||||||
|
```
|
||||||
|
my-theme/
|
||||||
|
├── theme.json
|
||||||
|
├── theme.css
|
||||||
|
└── assets/
|
||||||
|
├── fonts/
|
||||||
|
│ └── custom-font.woff2
|
||||||
|
└── images/
|
||||||
|
└── pattern.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Register Theme
|
||||||
|
|
||||||
|
Update `theme-manager.js` to include your theme:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
this.availableThemes.set('my-theme', {
|
||||||
|
name: 'My Custom Theme',
|
||||||
|
path: 'my-theme',
|
||||||
|
description: 'A custom theme for NOSTR_LOGIN_LITE'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Themes
|
||||||
|
|
||||||
|
### Initialization
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await NOSTR_LOGIN_LITE.init({
|
||||||
|
theme: 'default',
|
||||||
|
themePath: './themes/'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Switching
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Switch theme
|
||||||
|
await NOSTR_LOGIN_LITE.switchTheme('dark');
|
||||||
|
|
||||||
|
// Get current theme
|
||||||
|
const current = NOSTR_LOGIN_LITE.getCurrentTheme();
|
||||||
|
|
||||||
|
// List available themes
|
||||||
|
const available = NOSTR_LOGIN_LITE.getAvailableThemes();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Variables
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Set custom variable
|
||||||
|
NOSTR_LOGIN_LITE.setThemeVariable('--nl-accent-color', '#ff00ff');
|
||||||
|
|
||||||
|
// Get variable value
|
||||||
|
const value = NOSTR_LOGIN_LITE.getThemeVariable('--nl-accent-color');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Export
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Export current theme configuration
|
||||||
|
const themeData = NOSTR_LOGIN_LITE.exportTheme();
|
||||||
|
console.log(JSON.stringify(themeData, null, 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Guidelines
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- Ensure sufficient color contrast (4.5:1 minimum)
|
||||||
|
- Test with screen readers
|
||||||
|
- Support high contrast mode
|
||||||
|
- Use semantic color names
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Minimize CSS file size
|
||||||
|
- Optimize asset files
|
||||||
|
- Use web-safe fonts as fallbacks
|
||||||
|
- Consider loading performance
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- Test across browsers
|
||||||
|
- Ensure mobile responsiveness
|
||||||
|
- Validate CSS custom property support
|
||||||
|
- Test with different font sizes
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
- Use consistent naming conventions
|
||||||
|
- Provide clear documentation
|
||||||
|
- Include preview images
|
||||||
|
- Tag themes appropriately
|
||||||
|
- Test thoroughly before submission
|
||||||
|
|
||||||
|
## Community Contributions
|
||||||
|
|
||||||
|
### Submission Process
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create theme in `themes/community/your-theme/`
|
||||||
|
3. Follow all guidelines above
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit pull request with:
|
||||||
|
- Theme files
|
||||||
|
- Preview screenshot
|
||||||
|
- Documentation updates
|
||||||
|
|
||||||
|
### Review Criteria
|
||||||
|
- Code quality and organization
|
||||||
|
- Accessibility compliance
|
||||||
|
- Cross-browser compatibility
|
||||||
|
- Unique design contribution
|
||||||
|
- Proper documentation
|
||||||
|
|
||||||
|
## Built-in Themes
|
||||||
|
|
||||||
|
### Default Theme
|
||||||
|
- **Colors**: Black/white/red
|
||||||
|
- **Typography**: Courier New monospace
|
||||||
|
- **Style**: Clean, minimalist, accessible
|
||||||
|
- **Use Case**: General purpose, high readability
|
||||||
|
|
||||||
|
### Dark Theme
|
||||||
|
- **Colors**: Green/black/magenta
|
||||||
|
- **Typography**: Courier New monospace
|
||||||
|
- **Style**: Cyberpunk, terminal-inspired
|
||||||
|
- **Use Case**: Low light environments, developer aesthetic
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### ThemeManager Class
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const themeManager = new NostrThemeManager();
|
||||||
|
|
||||||
|
// Load theme
|
||||||
|
await themeManager.loadTheme('theme-name');
|
||||||
|
|
||||||
|
// Switch theme
|
||||||
|
await themeManager.switchTheme('theme-name');
|
||||||
|
|
||||||
|
// Get available themes
|
||||||
|
const themes = themeManager.getAvailableThemes();
|
||||||
|
|
||||||
|
// Set/get variables
|
||||||
|
themeManager.setThemeVariable('--nl-accent-color', '#ff0000');
|
||||||
|
const value = themeManager.getThemeVariable('--nl-accent-color');
|
||||||
|
|
||||||
|
// Export current theme
|
||||||
|
const exported = themeManager.exportCurrentTheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
### NOSTR_LOGIN_LITE Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initialize with theme
|
||||||
|
await NOSTR_LOGIN_LITE.init({ theme: 'dark' });
|
||||||
|
|
||||||
|
// Theme management
|
||||||
|
await NOSTR_LOGIN_LITE.switchTheme('theme-name');
|
||||||
|
const current = NOSTR_LOGIN_LITE.getCurrentTheme();
|
||||||
|
const available = NOSTR_LOGIN_LITE.getAvailableThemes();
|
||||||
|
|
||||||
|
// Variable management
|
||||||
|
NOSTR_LOGIN_LITE.setThemeVariable('--nl-primary-color', '#000000');
|
||||||
|
const color = NOSTR_LOGIN_LITE.getThemeVariable('--nl-primary-color');
|
||||||
|
|
||||||
|
// Export functionality
|
||||||
|
const themeData = NOSTR_LOGIN_LITE.exportTheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
The theme system dispatches events for integration:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Theme change event
|
||||||
|
window.addEventListener('nlThemeChanged', (event) => {
|
||||||
|
console.log('New theme:', event.detail.theme);
|
||||||
|
console.log('Theme data:', event.detail.data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Theme Not Loading
|
||||||
|
- Check theme.json syntax
|
||||||
|
- Verify file paths
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure CSS custom properties are supported
|
||||||
|
|
||||||
|
### Variables Not Applying
|
||||||
|
- Verify CSS custom property names (--nl- prefix)
|
||||||
|
- Check CSS specificity
|
||||||
|
- Ensure theme CSS is loaded after base styles
|
||||||
|
- Validate variable values
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
- Optimize CSS file size
|
||||||
|
- Compress assets
|
||||||
|
- Use efficient selectors
|
||||||
|
- Consider lazy loading for large themes
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The theme system is open source under the MIT license. Individual themes may have their own licenses as specified in their theme.json files.
|
||||||
115
themes/dark/theme.css
Normal file
115
themes/dark/theme.css
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* NOSTR_LOGIN_LITE - Dark Monospace Theme
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Core Variables (6) */
|
||||||
|
--nl-primary-color: #white;
|
||||||
|
--nl-secondary-color: #black;
|
||||||
|
--nl-accent-color: #ff0000;
|
||||||
|
--nl-muted-color: #666666;
|
||||||
|
--nl-font-family: "Courier New", Courier, monospace;
|
||||||
|
--nl-border-radius: 15px;
|
||||||
|
--nl-border-width: 3px;
|
||||||
|
|
||||||
|
/* Floating Tab Variables (8) */
|
||||||
|
--nl-tab-bg-logged-out: #ffffff;
|
||||||
|
--nl-tab-bg-logged-in: #000000;
|
||||||
|
--nl-tab-bg-opacity-logged-out: 0.9;
|
||||||
|
--nl-tab-bg-opacity-logged-in: 0.8;
|
||||||
|
--nl-tab-color-logged-out: #000000;
|
||||||
|
--nl-tab-color-logged-in: #ffffff;
|
||||||
|
--nl-tab-border-logged-out: #000000;
|
||||||
|
--nl-tab-border-logged-in: #ff0000;
|
||||||
|
--nl-tab-border-opacity-logged-out: 1.0;
|
||||||
|
--nl-tab-border-opacity-logged-in: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base component styles using simplified variables */
|
||||||
|
.nl-component {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button:hover {
|
||||||
|
border-color: var(--nl-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button:active {
|
||||||
|
background: var(--nl-accent-color);
|
||||||
|
color: var(--nl-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-input {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-input:focus {
|
||||||
|
border-color: var(--nl-accent-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-container {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-title, .nl-heading {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-text {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-text--muted {
|
||||||
|
color: var(--nl-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-icon {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating tab styles */
|
||||||
|
.nl-floating-tab {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
border: var(--nl-border-width) solid;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-floating-tab--logged-out {
|
||||||
|
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||||||
|
color: var(--nl-tab-color-logged-out);
|
||||||
|
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-floating-tab--logged-in {
|
||||||
|
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||||||
|
color: var(--nl-tab-color-logged-in);
|
||||||
|
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-transition {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
117
themes/default/theme.css
Normal file
117
themes/default/theme.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* NOSTR_LOGIN_LITE - Default Monospace Theme
|
||||||
|
* Black/white/red color scheme with monospace typography
|
||||||
|
* Simplified 14-variable system (6 core + 8 floating tab)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Core Variables (6) */
|
||||||
|
--nl-primary-color: #000000;
|
||||||
|
--nl-secondary-color: #ffffff;
|
||||||
|
--nl-accent-color: #ff0000;
|
||||||
|
--nl-muted-color: #666666;
|
||||||
|
--nl-font-family: "Courier New", Courier, monospace;
|
||||||
|
--nl-border-radius: 15px;
|
||||||
|
--nl-border-width: 3px;
|
||||||
|
|
||||||
|
/* Floating Tab Variables (8) */
|
||||||
|
--nl-tab-bg-logged-out: #ffffff;
|
||||||
|
--nl-tab-bg-logged-in: #ffffff;
|
||||||
|
--nl-tab-bg-opacity-logged-out: 0.9;
|
||||||
|
--nl-tab-bg-opacity-logged-in: 0.2;
|
||||||
|
--nl-tab-color-logged-out: #000000;
|
||||||
|
--nl-tab-color-logged-in: #ffffff;
|
||||||
|
--nl-tab-border-logged-out: #000000;
|
||||||
|
--nl-tab-border-logged-in: #ff0000;
|
||||||
|
--nl-tab-border-opacity-logged-out: 1.0;
|
||||||
|
--nl-tab-border-opacity-logged-in: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base component styles using simplified variables */
|
||||||
|
.nl-component {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button:hover {
|
||||||
|
border-color: var(--nl-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-button:active {
|
||||||
|
background: var(--nl-accent-color);
|
||||||
|
color: var(--nl-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-input {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-input:focus {
|
||||||
|
border-color: var(--nl-accent-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-container {
|
||||||
|
background: var(--nl-secondary-color);
|
||||||
|
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-title, .nl-heading {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-text {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-text--muted {
|
||||||
|
color: var(--nl-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-icon {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
color: var(--nl-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating tab styles */
|
||||||
|
.nl-floating-tab {
|
||||||
|
font-family: var(--nl-font-family);
|
||||||
|
border-radius: var(--nl-border-radius);
|
||||||
|
border: var(--nl-border-width) solid;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-floating-tab--logged-out {
|
||||||
|
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||||||
|
color: var(--nl-tab-color-logged-out);
|
||||||
|
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-floating-tab--logged-in {
|
||||||
|
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||||||
|
color: var(--nl-tab-color-logged-in);
|
||||||
|
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nl-transition {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
35
themes/index.json
Normal file
35
themes/index.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"themes": {
|
||||||
|
"default": {
|
||||||
|
"name": "Default Monospace",
|
||||||
|
"path": "default",
|
||||||
|
"description": "Black/white/red monospace theme with rounded buttons",
|
||||||
|
"author": "NOSTR_LOGIN_LITE",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"preview": "default/preview.png",
|
||||||
|
"tags": ["monospace", "minimalist", "accessibility", "default"],
|
||||||
|
"featured": true
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"name": "Dark Monospace",
|
||||||
|
"path": "dark",
|
||||||
|
"description": "Dark mode with green accents and monospace typography",
|
||||||
|
"author": "NOSTR_LOGIN_LITE",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"preview": "dark/preview.png",
|
||||||
|
"tags": ["dark", "cyberpunk", "monospace", "accessibility"],
|
||||||
|
"featured": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"official": ["default", "dark"],
|
||||||
|
"community": [],
|
||||||
|
"experimental": []
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"total_themes": 2,
|
||||||
|
"last_updated": "2025-01-14T11:13:00.000Z",
|
||||||
|
"schema_version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
286
themes/theme-manager.js
Normal file
286
themes/theme-manager.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* NOSTR_LOGIN_LITE Theme Manager
|
||||||
|
* Handles theme loading, switching, and CSS custom property management
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NostrThemeManager {
|
||||||
|
constructor() {
|
||||||
|
this.currentTheme = null;
|
||||||
|
this.availableThemes = new Map();
|
||||||
|
this.themeCache = new Map();
|
||||||
|
this.basePath = './themes/';
|
||||||
|
|
||||||
|
// Initialize with default theme
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// Load available themes index
|
||||||
|
await this.loadThemeIndex();
|
||||||
|
|
||||||
|
// Set default theme if none is set
|
||||||
|
if (!this.currentTheme) {
|
||||||
|
await this.loadTheme('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('NostrThemeManager: Initialized with themes:', Array.from(this.availableThemes.keys()));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('NostrThemeManager: Initialization failed:', error);
|
||||||
|
this.fallbackToInlineStyles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadThemeIndex() {
|
||||||
|
// For now, we'll manually register available themes
|
||||||
|
// In production, this could fetch from a themes.json index file
|
||||||
|
this.availableThemes.set('default', {
|
||||||
|
name: 'Default Monospace',
|
||||||
|
path: 'default',
|
||||||
|
description: 'Black/white/red monospace theme'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.availableThemes.set('dark', {
|
||||||
|
name: 'Dark Monospace',
|
||||||
|
path: 'dark',
|
||||||
|
description: 'Dark mode with green accents and monospace typography'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Future themes can be registered here or loaded from an index
|
||||||
|
// this.availableThemes.set('cyberpunk', { ... });
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTheme(themeName) {
|
||||||
|
try {
|
||||||
|
console.log(`NostrThemeManager: Loading theme "${themeName}"`);
|
||||||
|
|
||||||
|
// Check if theme exists
|
||||||
|
if (!this.availableThemes.has(themeName)) {
|
||||||
|
throw new Error(`Theme "${themeName}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (this.themeCache.has(themeName)) {
|
||||||
|
const cachedTheme = this.themeCache.get(themeName);
|
||||||
|
this.applyTheme(cachedTheme);
|
||||||
|
this.currentTheme = themeName;
|
||||||
|
return cachedTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load theme metadata
|
||||||
|
const themeInfo = this.availableThemes.get(themeName);
|
||||||
|
const metadataUrl = `${this.basePath}${themeInfo.path}/theme.json`;
|
||||||
|
|
||||||
|
const response = await fetch(metadataUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load theme metadata: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeData = await response.json();
|
||||||
|
|
||||||
|
// Validate theme data
|
||||||
|
this.validateThemeData(themeData);
|
||||||
|
|
||||||
|
// Load CSS file
|
||||||
|
await this.loadThemeCSS(themeInfo.path);
|
||||||
|
|
||||||
|
// Cache the theme data
|
||||||
|
this.themeCache.set(themeName, themeData);
|
||||||
|
|
||||||
|
// Apply the theme
|
||||||
|
this.applyTheme(themeData);
|
||||||
|
this.currentTheme = themeName;
|
||||||
|
|
||||||
|
console.log(`NostrThemeManager: Successfully loaded theme "${themeName}"`);
|
||||||
|
|
||||||
|
// Dispatch theme change event
|
||||||
|
this.dispatchThemeChangeEvent(themeName, themeData);
|
||||||
|
|
||||||
|
return themeData;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`NostrThemeManager: Failed to load theme "${themeName}":`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadThemeCSS(themePath) {
|
||||||
|
const cssUrl = `${this.basePath}${themePath}/theme.css`;
|
||||||
|
|
||||||
|
// Remove existing theme CSS
|
||||||
|
const existingThemeCSS = document.getElementById('nl-theme-css');
|
||||||
|
if (existingThemeCSS) {
|
||||||
|
existingThemeCSS.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load new theme CSS
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.id = 'nl-theme-css';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.type = 'text/css';
|
||||||
|
link.href = cssUrl;
|
||||||
|
|
||||||
|
// Wait for CSS to load
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
link.onload = resolve;
|
||||||
|
link.onerror = () => reject(new Error(`Failed to load CSS from ${cssUrl}`));
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(themeData) {
|
||||||
|
if (!themeData.variables) {
|
||||||
|
console.warn('NostrThemeManager: Theme data has no variables to apply');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Apply CSS custom properties
|
||||||
|
Object.entries(themeData.variables).forEach(([property, value]) => {
|
||||||
|
root.style.setProperty(property, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`NostrThemeManager: Applied ${Object.keys(themeData.variables).length} CSS variables`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateThemeData(themeData) {
|
||||||
|
const required = ['name', 'version', 'variables'];
|
||||||
|
|
||||||
|
for (const field of required) {
|
||||||
|
if (!themeData[field]) {
|
||||||
|
throw new Error(`Theme validation failed: missing required field "${field}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof themeData.variables !== 'object') {
|
||||||
|
throw new Error('Theme validation failed: variables must be an object');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackToInlineStyles() {
|
||||||
|
console.log('NostrThemeManager: Falling back to inline styles');
|
||||||
|
|
||||||
|
// Apply default theme variables directly
|
||||||
|
const defaultVariables = {
|
||||||
|
'--nl-primary-color': '#000000',
|
||||||
|
'--nl-secondary-color': '#ffffff',
|
||||||
|
'--nl-accent-color': '#ff0000',
|
||||||
|
'--nl-font-family': '"Courier New", Courier, monospace',
|
||||||
|
'--nl-border-radius': '15px',
|
||||||
|
'--nl-border-width': '3px',
|
||||||
|
'--nl-border-style': 'solid',
|
||||||
|
'--nl-padding-button': '12px 16px',
|
||||||
|
'--nl-padding-container': '20px 24px',
|
||||||
|
'--nl-font-size-base': '14px',
|
||||||
|
'--nl-font-size-title': '24px',
|
||||||
|
'--nl-font-size-button': '16px',
|
||||||
|
'--nl-transition-duration': '0.2s'
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
Object.entries(defaultVariables).forEach(([property, value]) => {
|
||||||
|
root.style.setProperty(property, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentTheme = 'fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchThemeChangeEvent(themeName, themeData) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const event = new CustomEvent('nlThemeChanged', {
|
||||||
|
detail: {
|
||||||
|
theme: themeName,
|
||||||
|
data: themeData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods
|
||||||
|
getCurrentTheme() {
|
||||||
|
return this.currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableThemes() {
|
||||||
|
return Array.from(this.availableThemes.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
getThemeInfo(themeName) {
|
||||||
|
return this.availableThemes.get(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchTheme(themeName) {
|
||||||
|
return await this.loadTheme(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getThemeVariable(variableName) {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const style = getComputedStyle(root);
|
||||||
|
return style.getPropertyValue(variableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeVariable(variableName, value) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty(variableName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Remove all nl- prefixed custom properties
|
||||||
|
const style = getComputedStyle(root);
|
||||||
|
for (let i = 0; i < style.length; i++) {
|
||||||
|
const property = style[i];
|
||||||
|
if (property.startsWith('--nl-')) {
|
||||||
|
root.style.removeProperty(property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove theme CSS
|
||||||
|
const themeCSS = document.getElementById('nl-theme-css');
|
||||||
|
if (themeCSS) {
|
||||||
|
themeCSS.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTheme = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme creation utilities (for developers)
|
||||||
|
exportCurrentTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const style = getComputedStyle(root);
|
||||||
|
const variables = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < style.length; i++) {
|
||||||
|
const property = style[i];
|
||||||
|
if (property.startsWith('--nl-')) {
|
||||||
|
variables[property] = style.getPropertyValue(property);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Custom Theme',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'User',
|
||||||
|
description: 'Exported theme',
|
||||||
|
variables,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in NOSTR_LOGIN_LITE
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.NostrThemeManager = NostrThemeManager;
|
||||||
|
console.log('NostrThemeManager: Class available globally');
|
||||||
|
} else {
|
||||||
|
// Node.js environment
|
||||||
|
module.exports = NostrThemeManager;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user