Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6fa2f15e4 | ||
|
|
e28ab948b0 | ||
|
|
3f34eb288d | ||
|
|
8424f0ca44 | ||
|
|
48c6739d25 | ||
|
|
b837dcb5f0 |
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
|
||||
"git.mleku.dev/mleku/nostr/httpauth"
|
||||
@@ -35,13 +34,24 @@ type CashuMintResponse struct {
|
||||
func (s *Server) handleCashuMint(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if Cashu is enabled
|
||||
if s.CashuIssuer == nil {
|
||||
log.W.F("Cashu mint request but issuer not initialized")
|
||||
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// Require NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
if err != nil {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if len(authHeader) > 100 {
|
||||
authHeader = authHeader[:100] + "..."
|
||||
}
|
||||
log.W.F("Cashu mint NIP-98 auth error: %v (valid=%v, authHeader=%q)", err, valid, authHeader)
|
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.W.F("Cashu mint NIP-98 auth invalid signature")
|
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
30
app/main.go
30
app/main.go
@@ -23,6 +23,10 @@ import (
|
||||
"next.orly.dev/pkg/protocol/nip43"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
"next.orly.dev/pkg/bunker"
|
||||
"next.orly.dev/pkg/cashu/issuer"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/verifier"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
"next.orly.dev/pkg/ratelimit"
|
||||
"next.orly.dev/pkg/spider"
|
||||
dsync "next.orly.dev/pkg/sync"
|
||||
@@ -162,6 +166,32 @@ func Run(
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Cashu access token system when ACL is active
|
||||
if cfg.ACLMode != "none" {
|
||||
// Create keyset manager with file-based store (keysets persist across restarts)
|
||||
keysetPath := filepath.Join(cfg.DataDir, "cashu-keysets.json")
|
||||
keysetStore, err := keyset.NewFileStore(keysetPath)
|
||||
if err != nil {
|
||||
log.E.F("failed to create Cashu keyset store at %s: %v", keysetPath, err)
|
||||
} else {
|
||||
keysetManager := keyset.NewManager(keysetStore, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
|
||||
// Initialize keyset manager (loads existing keysets or creates new one)
|
||||
if err := keysetManager.Init(); err != nil {
|
||||
log.E.F("failed to initialize Cashu keyset manager: %v", err)
|
||||
} else {
|
||||
// Create issuer with permissive checker (ACL handles authorization)
|
||||
issuerCfg := issuer.DefaultConfig()
|
||||
l.CashuIssuer = issuer.New(keysetManager, cashuiface.AllowAllChecker{}, issuerCfg)
|
||||
|
||||
// Create verifier for validating tokens
|
||||
l.CashuVerifier = verifier.New(keysetManager, cashuiface.AllowAllChecker{}, verifier.DefaultConfig())
|
||||
|
||||
log.I.F("Cashu access token system enabled (ACL mode: %s, keysets: %s)", cfg.ACLMode, keysetPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize spider manager based on mode (only for Badger backend)
|
||||
if badgerDB, ok := db.(*database.D); ok && cfg.SpiderMode != "none" {
|
||||
if l.spiderManager, err = spider.New(ctx, badgerDB, l.publishers, cfg.SpiderMode); chk.E(err) {
|
||||
|
||||
2
app/web/dist/bundle.css
vendored
2
app/web/dist/bundle.css
vendored
File diff suppressed because one or more lines are too long
40
app/web/dist/bundle.js
vendored
40
app/web/dist/bundle.js
vendored
File diff suppressed because one or more lines are too long
2
app/web/dist/bundle.js.map
vendored
2
app/web/dist/bundle.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -28,8 +28,110 @@
|
||||
let isServiceActive = false;
|
||||
let isStartingService = false;
|
||||
let connectedClients = [];
|
||||
let catToken = null;
|
||||
let catTokenEncoded = "";
|
||||
let serviceCatToken = null; // Token for ORLY's own relay connection
|
||||
|
||||
// Client tokens list - each device gets its own token
|
||||
let clientTokens = []; // [{id, name, token, encoded, createdAt, isEditing}]
|
||||
let selectedTokenId = null; // Currently selected token for the QR code
|
||||
|
||||
// Two-word name generator
|
||||
const adjectives = ["brave", "calm", "clever", "cosmic", "cozy", "daring", "eager", "fancy", "gentle", "happy", "jolly", "keen", "lively", "merry", "nimble", "peppy", "quick", "rustic", "shiny", "swift", "tender", "vivid", "witty", "zesty"];
|
||||
const nouns = ["badger", "bunny", "coral", "dolphin", "falcon", "gecko", "heron", "iguana", "jaguar", "koala", "lemur", "mango", "narwhal", "otter", "panda", "quail", "rabbit", "salmon", "turtle", "urchin", "viper", "walrus", "yak", "zebra"];
|
||||
|
||||
function generateTokenName() {
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
return `${adj}-${noun}`;
|
||||
}
|
||||
|
||||
function generateTokenId() {
|
||||
return crypto.randomUUID().split('-')[0];
|
||||
}
|
||||
|
||||
// Add a new client token
|
||||
async function addClientToken(mintInfo, signHttpAuth) {
|
||||
const token = await requestToken(
|
||||
mintInfo.mintUrl,
|
||||
TokenScope.NIP46,
|
||||
hexToBytes(userPubkey),
|
||||
signHttpAuth,
|
||||
[24133]
|
||||
);
|
||||
const encoded = encodeToken(token);
|
||||
const id = generateTokenId();
|
||||
const newToken = {
|
||||
id,
|
||||
name: generateTokenName(),
|
||||
token,
|
||||
encoded,
|
||||
createdAt: Date.now(),
|
||||
isExpanded: false
|
||||
};
|
||||
clientTokens = [...clientTokens, newToken];
|
||||
// Select the new token if none selected
|
||||
if (!selectedTokenId) {
|
||||
selectedTokenId = id;
|
||||
}
|
||||
console.log(`Client token "${newToken.name}" created, expires:`, new Date(token.expiry * 1000).toISOString());
|
||||
return newToken;
|
||||
}
|
||||
|
||||
// Add a new token (called from UI)
|
||||
async function handleAddToken() {
|
||||
if (!bunkerInfo?.cashu_enabled) return;
|
||||
|
||||
try {
|
||||
const mintInfo = await getMintInfo(bunkerInfo.relay_url);
|
||||
if (!mintInfo) return;
|
||||
|
||||
const signHttpAuth = async (url, method) => {
|
||||
const header = await createNIP98Auth(userSigner, userPubkey, method, url);
|
||||
return `Nostr ${header}`;
|
||||
};
|
||||
|
||||
await addClientToken(mintInfo, signHttpAuth);
|
||||
// Regenerate QR for newly selected token
|
||||
await generateQRCodes();
|
||||
} catch (err) {
|
||||
console.error("Failed to add token:", err);
|
||||
error = err.message || "Failed to add token";
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke/remove a client token
|
||||
function revokeToken(tokenId) {
|
||||
clientTokens = clientTokens.filter(t => t.id !== tokenId);
|
||||
// If we removed the selected token, select another
|
||||
if (selectedTokenId === tokenId) {
|
||||
selectedTokenId = clientTokens.length > 0 ? clientTokens[0].id : null;
|
||||
}
|
||||
generateQRCodes();
|
||||
}
|
||||
|
||||
// Toggle token details expansion
|
||||
function toggleTokenExpand(tokenId) {
|
||||
clientTokens = clientTokens.map(t =>
|
||||
t.id === tokenId ? { ...t, isExpanded: !t.isExpanded } : t
|
||||
);
|
||||
}
|
||||
|
||||
// Update token name
|
||||
function updateTokenName(tokenId, newName) {
|
||||
clientTokens = clientTokens.map(t =>
|
||||
t.id === tokenId ? { ...t, name: newName } : t
|
||||
);
|
||||
}
|
||||
|
||||
// Generate QR code for a specific token
|
||||
async function generateTokenQR(token) {
|
||||
if (!bunkerInfo || !userPubkey) return null;
|
||||
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${token.encoded}`;
|
||||
return await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: "#000000", light: "#ffffff" }
|
||||
});
|
||||
}
|
||||
|
||||
$: canAccess = isLoggedIn && userPubkey && (
|
||||
currentEffectiveRole === "write" ||
|
||||
@@ -38,8 +140,10 @@
|
||||
);
|
||||
|
||||
// Generate bunker URLs when bunkerInfo and userPubkey are available
|
||||
$: clientBunkerURL = bunkerInfo && userPubkey ?
|
||||
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}${catTokenEncoded ? `&cat=${catTokenEncoded}` : ''}` : "";
|
||||
// Get selected token for the bunker URL
|
||||
$: selectedToken = clientTokens.find(t => t.id === selectedTokenId);
|
||||
$: clientBunkerURL = bunkerInfo && userPubkey && selectedToken ?
|
||||
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${selectedToken.encoded}` : "";
|
||||
|
||||
$: signerBunkerURL = bunkerInfo ?
|
||||
`nostr+connect://${bunkerInfo.relay_url}` : "";
|
||||
@@ -68,9 +172,9 @@
|
||||
error = "";
|
||||
|
||||
try {
|
||||
// Check if CAT is required and mint one
|
||||
// Check if CAT is required and mint tokens
|
||||
if (bunkerInfo.cashu_enabled) {
|
||||
console.log("CAT required, minting token...");
|
||||
console.log("CAT required, minting tokens...");
|
||||
const mintInfo = await getMintInfo(bunkerInfo.relay_url);
|
||||
if (mintInfo) {
|
||||
// Create NIP-98 auth function
|
||||
@@ -79,16 +183,18 @@
|
||||
return `Nostr ${header}`;
|
||||
};
|
||||
|
||||
// Request NIP-46 scoped token
|
||||
catToken = await requestToken(
|
||||
// 1. Token for ORLY's BunkerService relay connection
|
||||
serviceCatToken = await requestToken(
|
||||
mintInfo.mintUrl,
|
||||
TokenScope.NIP46,
|
||||
hexToBytes(userPubkey),
|
||||
signHttpAuth,
|
||||
[24133]
|
||||
);
|
||||
catTokenEncoded = encodeToken(catToken);
|
||||
console.log("CAT token acquired, expires:", new Date(catToken.expiry * 1000).toISOString());
|
||||
console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString());
|
||||
|
||||
// 2. Create first client token
|
||||
await addClientToken(mintInfo, signHttpAuth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +210,9 @@
|
||||
bunkerService.addAllowedSecret(bunkerSecret);
|
||||
}
|
||||
|
||||
// Set CAT token if available
|
||||
if (catToken) {
|
||||
bunkerService.setCatToken(catToken);
|
||||
// Set CAT token for service connection
|
||||
if (serviceCatToken) {
|
||||
bunkerService.setCatToken(serviceCatToken);
|
||||
}
|
||||
|
||||
// Set up callbacks
|
||||
@@ -149,8 +255,9 @@
|
||||
}
|
||||
isServiceActive = false;
|
||||
connectedClients = [];
|
||||
catToken = null;
|
||||
catTokenEncoded = "";
|
||||
serviceCatToken = null;
|
||||
clientTokens = [];
|
||||
selectedTokenId = null;
|
||||
// Regenerate QR codes without CAT token
|
||||
generateQRCodes();
|
||||
}
|
||||
@@ -299,52 +406,99 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if catToken}
|
||||
<div class="cat-info">
|
||||
<span class="cat-badge">CAT Token Active</span>
|
||||
<span class="cat-expiry">Expires: {new Date(catToken.expiry * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="qr-sections">
|
||||
<!-- Client QR Code -->
|
||||
<section class="qr-section">
|
||||
<h4>Bunker URL for Client Apps</h4>
|
||||
<p class="section-desc">
|
||||
{#if isServiceActive}
|
||||
Scan or copy this URL in your Nostr client (e.g., Smesh) to connect:
|
||||
{:else}
|
||||
Start the bunker service above to generate a connection URL.
|
||||
{/if}
|
||||
</p>
|
||||
<!-- Client Tokens Table -->
|
||||
{#if isServiceActive && clientTokens.length > 0}
|
||||
<div class="tokens-section">
|
||||
<div class="tokens-header">
|
||||
<h4>Client Tokens</h4>
|
||||
<button class="add-token-btn" on:click={handleAddToken}>+ Add Token</button>
|
||||
</div>
|
||||
<p class="tokens-desc">Each device/app gets its own token. Tokens can be individually revoked.</p>
|
||||
|
||||
<div
|
||||
class="qr-container clickable"
|
||||
on:click={() => copyToClipboard(clientBunkerURL, "client")}
|
||||
on:keypress={(e) => e.key === 'Enter' && copyToClipboard(clientBunkerURL, "client")}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Click to copy bunker URL"
|
||||
>
|
||||
{#if clientQrDataUrl}
|
||||
<img src={clientQrDataUrl} alt="Client Bunker QR Code" class="qr-code" />
|
||||
<div class="qr-overlay" class:visible={copiedItem === "client"}>
|
||||
Copied!
|
||||
<div class="tokens-table">
|
||||
{#each clientTokens as tokenEntry (tokenEntry.id)}
|
||||
<div class="token-row" class:expanded={tokenEntry.isExpanded}>
|
||||
<div class="token-main" on:click={() => toggleTokenExpand(tokenEntry.id)} on:keypress={(e) => e.key === 'Enter' && toggleTokenExpand(tokenEntry.id)} role="button" tabindex="0">
|
||||
<span class="expand-icon">{tokenEntry.isExpanded ? '▼' : '▶'}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="token-name-input"
|
||||
value={tokenEntry.name}
|
||||
on:input={(e) => updateTokenName(tokenEntry.id, e.target.value)}
|
||||
on:click|stopPropagation
|
||||
placeholder="Token name"
|
||||
/>
|
||||
<span class="token-created">
|
||||
{new Date(tokenEntry.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span class="token-expiry">
|
||||
Expires: {new Date(tokenEntry.token.expiry * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
class="revoke-btn"
|
||||
on:click|stopPropagation={() => revokeToken(tokenEntry.id)}
|
||||
title="Revoke this token"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tokenEntry.isExpanded}
|
||||
<div class="token-details">
|
||||
{#await generateTokenQR(tokenEntry)}
|
||||
<div class="qr-placeholder small">Loading QR...</div>
|
||||
{:then qrDataUrl}
|
||||
<div class="token-detail-content">
|
||||
<div
|
||||
class="qr-container small clickable"
|
||||
on:click={() => {
|
||||
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`;
|
||||
copyToClipboard(url, tokenEntry.id);
|
||||
}}
|
||||
on:keypress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`;
|
||||
copyToClipboard(url, tokenEntry.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Click to copy bunker URL"
|
||||
>
|
||||
<img src={qrDataUrl} alt="Token QR Code" class="qr-code small" />
|
||||
<div class="qr-overlay" class:visible={copiedItem === tokenEntry.id}>
|
||||
Copied!
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-info">
|
||||
<div class="info-item">
|
||||
<span class="label">Created:</span>
|
||||
<span>{new Date(tokenEntry.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Expires:</span>
|
||||
<span>{new Date(tokenEntry.token.expiry * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="info-item url-item">
|
||||
<span class="label">Bunker URL:</span>
|
||||
<code class="bunker-url small">{`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`}</code>
|
||||
</div>
|
||||
<div class="copy-hint">Click QR code to copy URL</div>
|
||||
</div>
|
||||
</div>
|
||||
{:catch}
|
||||
<div class="error-message">Failed to generate QR</div>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="qr-placeholder">Generating QR...</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="url-display">
|
||||
<code class="bunker-url">{clientBunkerURL}</code>
|
||||
</div>
|
||||
<div class="copy-hint">Click QR code to copy</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="connection-info">
|
||||
@@ -821,6 +975,187 @@
|
||||
background-color: var(--accent-hover-color);
|
||||
}
|
||||
|
||||
/* Token table styles */
|
||||
.tokens-section {
|
||||
background-color: var(--card-bg);
|
||||
padding: 1.25em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.tokens-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.tokens-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tokens-desc {
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.add-token-btn {
|
||||
background-color: var(--primary);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.add-token-btn:hover {
|
||||
background-color: var(--accent-hover-color);
|
||||
}
|
||||
|
||||
.tokens-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token-row.expanded {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.token-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
padding: 0.75em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.token-main:hover {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.token-name-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
max-width: 180px;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0.3em 0.5em;
|
||||
font-size: 0.95em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.token-name-input:hover {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.token-name-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.token-created, .token-expiry {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token-expiry {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.revoke-btn {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3em 0.6em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.revoke-btn:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.token-details {
|
||||
padding: 1em;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.token-detail-content {
|
||||
display: flex;
|
||||
gap: 1.5em;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qr-container.small {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-code.small {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.qr-placeholder.small {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.info-item.url-item {
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.bunker-url.small {
|
||||
font-size: 0.7em;
|
||||
padding: 0.5em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.qr-sections {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -834,5 +1169,42 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.token-main {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.token-name-input {
|
||||
order: 1;
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.token-created {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.token-expiry {
|
||||
order: 3;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.revoke-btn {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.token-detail-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
export async function createNIP98Auth(signer, pubkey, method, url) {
|
||||
if (!signer || !pubkey) {
|
||||
console.log("No signer or pubkey available");
|
||||
console.log("createNIP98Auth: No signer or pubkey available", { hasSigner: !!signer, hasPubkey: !!pubkey });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -23,17 +23,30 @@ export async function createNIP98Auth(signer, pubkey, method, url) {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["u", url],
|
||||
["method", method],
|
||||
["method", method.toUpperCase()],
|
||||
],
|
||||
content: "",
|
||||
};
|
||||
|
||||
console.log("createNIP98Auth: Signing event for", method, url);
|
||||
|
||||
// Sign using the signer
|
||||
const signedEvent = await signer.signEvent(authEvent);
|
||||
console.log("createNIP98Auth: Signed event:", {
|
||||
id: signedEvent.id,
|
||||
pubkey: signedEvent.pubkey,
|
||||
kind: signedEvent.kind,
|
||||
created_at: signedEvent.created_at,
|
||||
tags: signedEvent.tags,
|
||||
hasSig: !!signedEvent.sig
|
||||
});
|
||||
|
||||
// Use URL-safe base64 encoding (replace + with -, / with _)
|
||||
return btoa(JSON.stringify(signedEvent)).replace(/\+/g, '-').replace(/\//g, '_');
|
||||
const json = JSON.stringify(signedEvent);
|
||||
const base64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error("Error creating NIP-98 auth:", error);
|
||||
console.error("createNIP98Auth: Error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module next.orly.dev
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
git.mleku.dev/mleku/nostr v1.0.11
|
||||
git.mleku.dev/mleku/nostr v1.0.12
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/alexflint/go-arg v1.6.1
|
||||
github.com/aperturerobotics/go-indexeddb v0.2.3
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
||||
git.mleku.dev/mleku/nostr v1.0.11 h1:xQ+rKPzTblerX/kRLDimOsH3rQK7/n9wYdG4DBKGcsg=
|
||||
git.mleku.dev/mleku/nostr v1.0.11/go.mod h1:kJwSMmLRnAJ7QJtgXDv2wGgceFU0luwVqrgAL3MI93M=
|
||||
git.mleku.dev/mleku/nostr v1.0.12 h1:bjsFUh1Q3fGpU7qsqxggGgrGGUt2OBdu1w8hjDM4gJE=
|
||||
git.mleku.dev/mleku/nostr v1.0.12/go.mod h1:kJwSMmLRnAJ7QJtgXDv2wGgceFU0luwVqrgAL3MI93M=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||
|
||||
218
pkg/cashu/keyset/file_store.go
Normal file
218
pkg/cashu/keyset/file_store.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package keyset
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
)
|
||||
|
||||
// keysetData is the JSON-serializable form of a Keyset.
|
||||
type keysetData struct {
|
||||
ID string `json:"id"`
|
||||
PrivateKey string `json:"private_key"` // hex-encoded
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ActiveAt int64 `json:"active_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
VerifyEnd int64 `json:"verify_end"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// fileStoreData is the top-level JSON structure.
|
||||
type fileStoreData struct {
|
||||
Version int `json:"version"`
|
||||
Keysets []keysetData `json:"keysets"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FileStore persists keysets to a JSON file.
|
||||
type FileStore struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
keysets map[string]*Keyset
|
||||
}
|
||||
|
||||
// NewFileStore creates a new file-based keyset store.
|
||||
// The directory will be created if it doesn't exist.
|
||||
func NewFileStore(path string) (*FileStore, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("keyset: failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
store := &FileStore{
|
||||
path: path,
|
||||
keysets: make(map[string]*Keyset),
|
||||
}
|
||||
|
||||
// Load existing keysets if file exists
|
||||
if err := store.load(); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("keyset: failed to load keysets: %w", err)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// load reads keysets from the file.
|
||||
func (s *FileStore) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileData fileStoreData
|
||||
if err := json.Unmarshal(data, &fileData); err != nil {
|
||||
return fmt.Errorf("keyset: failed to parse file: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, kd := range fileData.Keysets {
|
||||
keyset, err := s.fromData(kd)
|
||||
if err != nil {
|
||||
// Log but continue - don't fail on single corrupt keyset
|
||||
continue
|
||||
}
|
||||
s.keysets[keyset.ID] = keyset
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes all keysets to the file.
|
||||
func (s *FileStore) save() error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
keysets := make([]keysetData, 0, len(s.keysets))
|
||||
for _, k := range s.keysets {
|
||||
keysets = append(keysets, s.toData(k))
|
||||
}
|
||||
|
||||
fileData := fileStoreData{
|
||||
Version: 1,
|
||||
Keysets: keysets,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(fileData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyset: failed to marshal: %w", err)
|
||||
}
|
||||
|
||||
// Write atomically using temp file
|
||||
tmpPath := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("keyset: failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, s.path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("keyset: failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toData converts a Keyset to its JSON form.
|
||||
func (s *FileStore) toData(k *Keyset) keysetData {
|
||||
return keysetData{
|
||||
ID: k.ID,
|
||||
PrivateKey: hex.EncodeToString(k.SerializePrivateKey()),
|
||||
CreatedAt: k.CreatedAt.Unix(),
|
||||
ActiveAt: k.ActiveAt.Unix(),
|
||||
ExpiresAt: k.ExpiresAt.Unix(),
|
||||
VerifyEnd: k.VerifyEnd.Unix(),
|
||||
Active: k.Active,
|
||||
}
|
||||
}
|
||||
|
||||
// fromData reconstructs a Keyset from its JSON form.
|
||||
func (s *FileStore) fromData(kd keysetData) (*Keyset, error) {
|
||||
privKeyBytes, err := hex.DecodeString(kd.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keyset: invalid private key hex: %w", err)
|
||||
}
|
||||
|
||||
if len(privKeyBytes) != 32 {
|
||||
return nil, fmt.Errorf("keyset: private key must be 32 bytes")
|
||||
}
|
||||
|
||||
privKey := secp256k1.PrivKeyFromBytes(privKeyBytes)
|
||||
pubKey := privKey.PubKey()
|
||||
|
||||
return &Keyset{
|
||||
ID: kd.ID,
|
||||
PrivateKey: privKey,
|
||||
PublicKey: pubKey,
|
||||
CreatedAt: time.Unix(kd.CreatedAt, 0),
|
||||
ActiveAt: time.Unix(kd.ActiveAt, 0),
|
||||
ExpiresAt: time.Unix(kd.ExpiresAt, 0),
|
||||
VerifyEnd: time.Unix(kd.VerifyEnd, 0),
|
||||
Active: kd.Active,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveKeyset persists a keyset.
|
||||
func (s *FileStore) SaveKeyset(k *Keyset) error {
|
||||
s.mu.Lock()
|
||||
s.keysets[k.ID] = k
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// LoadKeyset loads a keyset by ID.
|
||||
func (s *FileStore) LoadKeyset(id string) (*Keyset, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if k, ok := s.keysets[id]; ok {
|
||||
return k, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ListActiveKeysets returns all keysets that can be used for signing.
|
||||
func (s *FileStore) ListActiveKeysets() ([]*Keyset, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*Keyset, 0)
|
||||
for _, k := range s.keysets {
|
||||
if k.IsActiveForSigning() {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListVerificationKeysets returns all keysets that can be used for verification.
|
||||
func (s *FileStore) ListVerificationKeysets() ([]*Keyset, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]*Keyset, 0)
|
||||
for _, k := range s.keysets {
|
||||
if k.IsValidForVerification() {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteKeyset removes a keyset from storage.
|
||||
func (s *FileStore) DeleteKeyset(id string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.keysets, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.save()
|
||||
}
|
||||
@@ -355,7 +355,7 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
|
||||
return elem, nil
|
||||
|
||||
case TagElementPubkeySerial:
|
||||
// Pubkey serial: 5 bytes -> lookup full pubkey -> return as 32-byte binary
|
||||
// Pubkey serial: 5 bytes -> lookup full pubkey -> return as 33-byte binary
|
||||
serial, err := readUint40(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -364,11 +364,14 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Return as 32-byte binary (nostr library optimized format)
|
||||
return pubkey, nil
|
||||
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
|
||||
result := make([]byte, 33)
|
||||
copy(result, pubkey)
|
||||
result[32] = 0 // null terminator
|
||||
return result, nil
|
||||
|
||||
case TagElementEventSerial:
|
||||
// Event serial: 5 bytes -> lookup full event ID -> return as 32-byte binary
|
||||
// Event serial: 5 bytes -> lookup full event ID -> return as 33-byte binary
|
||||
serial, err := readUint40(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -377,15 +380,20 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Return as 32-byte binary
|
||||
return eventId, nil
|
||||
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
|
||||
result := make([]byte, 33)
|
||||
copy(result, eventId)
|
||||
result[32] = 0 // null terminator
|
||||
return result, nil
|
||||
|
||||
case TagElementEventIdFull:
|
||||
// Full event ID: 32 bytes (for unknown/forward references)
|
||||
elem = make([]byte, 32)
|
||||
if _, err = io.ReadFull(r, elem); err != nil {
|
||||
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
|
||||
elem = make([]byte, 33)
|
||||
if _, err = io.ReadFull(r, elem[:32]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elem[32] = 0 // null terminator
|
||||
return elem, nil
|
||||
|
||||
default:
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.44.0
|
||||
v0.44.4
|
||||
|
||||
Reference in New Issue
Block a user