fix: revert to nostrsigner: format, fix double-encoding of callbackUrl

Root cause of "malformed request":
- buildIntentUrl() was calling encodeURIComponent() on ALL values including
  callbackUrl, which was ALREADY a full URL with special chars
- This double-encoded the callbackUrl, arriving at Amber as garbage
- The intent: wrapper format was also unnecessary

Fix: back to clean nostrsigner: format per NIP-55 spec
- Only callbackUrl gets encodeURIComponent (once)
- All other params are plain query string values
- Matches exactly what NIP-55 spec shows in its example code

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-08 11:26:42 +07:00
parent 0b6af1bcdf
commit 6f303d1956
3 changed files with 40 additions and 36 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.0.28", "version": "1.0.29",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -1,5 +1,4 @@
v1.0.28 — Amber login fixed for real v1.0.29 — Amber login fixed (properly this time)
- Fixed "malformed request" error when logging in with Amber (NIP-55) - Root cause found: callbackUrl was being double-encoded inside the URL builder, arriving garbled at Amber and causing "malformed request"
- Switched to correct Android intent: URL format that browsers require - Reverted to clean nostrsigner: format per NIP-55 spec with only callbackUrl encoded once
- Previous versions used nostrsigner: directly which only works for native apps, not web/PWA

View file

@ -300,31 +300,36 @@ function buildCallbackUrl(): string {
/** /**
* Navigate to an Amber nostrsigner: URL. * Navigate to an Amber nostrsigner: URL.
* *
* For web apps the correct Android intent format is: * Per NIP-55 spec and Amber README, web clients use window.location.href
* intent:<payload>#Intent;scheme=nostrsigner;S.<key>=<value>;...;end * with a nostrsigner: URI. The params go in the query string unencoded
* (except the callbackUrl which must be encodeURIComponent'd so Amber
* can parse it as a single value).
* *
* This is the format Amber's GitHub README documents for web clients. * Correct format per NIP-55 spec:
* The nostrsigner: URL format (without intent: wrapper) causes the * nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=https://.../?event=
* "malformed request" error because Android doesn't route it correctly
* from a browser context.
*
* Reference: https://github.com/greenart7c3/Amber (Web Applications section)
*/ */
function openAmberUrl(url: string): void { function openAmberUrl(url: string): void {
window.location.href = url; window.location.href = url;
} }
/** /**
* Build an Android intent URL for Amber web usage. * Build a nostrsigner: URL for Amber web usage.
* Converts nostrsigner: params into the intent: format Amber expects from browsers.
* *
* Format: intent:<payload>#Intent;scheme=nostrsigner;S.<key>=<val>;...;end * Per NIP-55 spec the format is:
* nostrsigner:<payload>?type=<method>&compressionType=none&returnType=signature&callbackUrl=<encoded>
*
* The callbackUrl is the ONLY value that needs encodeURIComponent.
* All other params are plain query string values.
*/ */
function buildIntentUrl(payload: string, params: Record<string, string>): string { function buildAmberUrl(payload: string, params: Record<string, string>): string {
const extras = Object.entries(params) const query = Object.entries(params)
.map(([k, v]) => `S.${k}=${encodeURIComponent(v)}`) .map(([k, v]) => {
.join(';'); // callbackUrl must be encoded so Amber treats it as a single value
return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`; if (k === 'callbackUrl') return `${k}=${encodeURIComponent(v)}`;
return `${k}=${v}`;
})
.join('&');
return `nostrsigner:${payload}?${query}`;
} }
/** /**
@ -333,10 +338,10 @@ function buildIntentUrl(payload: string, params: Record<string, string>): string
* the app reads it on visibilitychange. * the app reads it on visibilitychange.
*/ */
export function openAmberForLoginNoCallback(): void { export function openAmberForLoginNoCallback(): void {
const amberUrl = buildIntentUrl('', { const amberUrl = buildAmberUrl('', {
type: 'get_public_key',
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'get_public_key',
}); });
openAmberUrl(amberUrl); openAmberUrl(amberUrl);
} }
@ -363,11 +368,11 @@ export function openAmberForLogin(): void {
const currentPath = window.location.hash.replace(/^#/, '') || '/'; const currentPath = window.location.hash.replace(/^#/, '') || '/';
localStorage.setItem('amber_return_path', currentPath); localStorage.setItem('amber_return_path', currentPath);
// Android intent format for web → Amber communication (per Amber README) // NIP-55 nostrsigner: URL — Amber opens, user approves, Amber calls callbackUrl
const amberUrl = buildIntentUrl('', { const amberUrl = buildAmberUrl('', {
type: 'get_public_key',
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'get_public_key',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });
@ -411,10 +416,10 @@ export function openAmberForSignEvent(event: Omit<NostrEvent, 'id' | 'sig'>): vo
timestamp: Date.now(), timestamp: Date.now(),
}); });
const amberUrl = buildIntentUrl(encodeURIComponent(eventJson), { const amberUrl = buildAmberUrl(encodeURIComponent(eventJson), {
type: 'sign_event',
compressionType: 'none', compressionType: 'none',
returnType: 'event', returnType: 'event',
type: 'sign_event',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });
@ -436,11 +441,11 @@ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): voi
timestamp: Date.now(), timestamp: Date.now(),
}); });
const amberUrl = buildIntentUrl(encodeURIComponent(plaintext), { const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), {
type: 'nip04_encrypt',
pubKey: pubkey, pubKey: pubkey,
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'nip04_encrypt',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });
@ -462,11 +467,11 @@ export function openAmberForNip04Decrypt(pubkey: string, ciphertext: string): vo
timestamp: Date.now(), timestamp: Date.now(),
}); });
const amberUrl = buildIntentUrl(encodeURIComponent(ciphertext), { const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), {
type: 'nip04_decrypt',
pubKey: pubkey, pubKey: pubkey,
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'nip04_decrypt',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });
@ -488,11 +493,11 @@ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): voi
timestamp: Date.now(), timestamp: Date.now(),
}); });
const amberUrl = buildIntentUrl(encodeURIComponent(plaintext), { const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), {
type: 'nip44_encrypt',
pubKey: pubkey, pubKey: pubkey,
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'nip44_encrypt',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });
@ -514,11 +519,11 @@ export function openAmberForNip44Decrypt(pubkey: string, ciphertext: string): vo
timestamp: Date.now(), timestamp: Date.now(),
}); });
const amberUrl = buildIntentUrl(encodeURIComponent(ciphertext), { const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), {
type: 'nip44_decrypt',
pubKey: pubkey, pubKey: pubkey,
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'nip44_decrypt',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });