fix: Amber intent: format + # in callbackUrl breaks intent parsing

Three compounding bugs fixed:

1. WRONG FORMAT: Was using nostrsigner: directly, then intent: with encoded
   values. Amber README shows intent: with raw unencoded S.key=value params.

2. # IN CALLBACKURL: Our callbackUrl was https://.../#/amber-callback which
   contains a # — but intent: uses # as its own delimiter (#Intent;...;end).
   Having a # inside the callbackUrl value breaks the entire intent string.
   Fixed: callbackUrl is now https://.../amber-callback?event= (no hash).

3. HASHROUTER MISMATCH: Amber opens /amber-callback (no hash) but HashRouter
   only handles /#/route paths. Fixed in main.tsx: pre-React IIFE detects
   /amber-callback path, saves ?event= to sessionStorage, then redirects to
   /#/amber-callback so React Router matches AmberCallbackPage correctly.

Final correct intent URL format:
  intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://skate-spots.org/amber-callback?event=;end

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-08 11:36:03 +07:00
parent 6f303d1956
commit 712b871e1a
4 changed files with 58 additions and 46 deletions

View file

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

View file

@ -1,4 +1,6 @@
v1.0.29 — Amber login fixed (properly this time) v1.0.30 — Amber login fixed definitively
- 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 per Amber README
- Reverted to clean nostrsigner: format per NIP-55 spec with only callbackUrl encoded once - Removed # from callbackUrl (# breaks the intent: format which uses # as delimiter)
- Added pre-redirect in main.tsx: when Amber opens /amber-callback, app redirects to /#/amber-callback so HashRouter can match the route
- All params passed raw (no encoding) inside intent: string as required

View file

@ -286,50 +286,48 @@ function generateOperationId(): string {
function buildCallbackUrl(): string { function buildCallbackUrl(): string {
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
if (isCapacitor) { if (isCapacitor) {
// Use the app's custom scheme so Amber fires the intent back into the APK.
// DeepLinkHandler in AppRouter.tsx catches appUrlOpen and navigates to the
// correct route inside the HashRouter.
return 'org.skatespots.app://amber-callback?event='; return 'org.skatespots.app://amber-callback?event=';
} }
// HashRouter: routes live in the # fragment, so the callback URL must be: // IMPORTANT: The callbackUrl is embedded RAW inside an intent: string that uses
// https://skate-spots.org/#/amber-callback?event=<result> // # as a delimiter (intent:<payload>#Intent;...). So the callbackUrl CANNOT
// This is what Amber will open in the browser tab after user approves. // contain a # character — it would break the intent format.
return `${window.location.origin}/#/amber-callback?event=`; //
// We use a plain URL without the hash fragment:
// https://skate-spots.org/amber-callback?event=<pubkey>
//
// The server (or Netlify/nsite) serves index.html for all paths, so the app
// loads at /amber-callback. main.tsx pre-captures ?event= before React hydrates,
// and AmberCallbackPage processes it via sessionStorage.
return `${window.location.origin}/amber-callback?event=`;
} }
/** /**
* Navigate to an Amber nostrsigner: URL. * Navigate to an Amber intent URL.
*
* Per NIP-55 spec and Amber README, web clients use window.location.href
* 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).
*
* Correct format per NIP-55 spec:
* nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=https://.../?event=
*/ */
function openAmberUrl(url: string): void { function openAmberUrl(url: string): void {
window.location.href = url; window.location.href = url;
} }
/** /**
* Build a nostrsigner: URL for Amber web usage. * Build an Android intent URL for Amber — the ONLY format that works from a browser.
* *
* Per NIP-55 spec the format is: * Per Amber README (https://github.com/greenart7c3/Amber) web apps section,
* nostrsigner:<payload>?type=<method>&compressionType=none&returnType=signature&callbackUrl=<encoded> * the exact format is:
* *
* The callbackUrl is the ONLY value that needs encodeURIComponent. * intent:<payload>#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://example.com/?event=;end
* All other params are plain query string values. *
* CRITICAL RULES:
* 1. Use intent: scheme, NOT nostrsigner: directly
* 2. Params go as S.<key>=<value> separated by semicolons
* 3. callbackUrl is NOT encoded — it is raw, including the trailing ?event=
* 4. payload (event JSON etc.) goes BEFORE the # and must be encodeURIComponent'd
* 5. window.href (not window.location.href, not window.open) per the spec
*/ */
function buildAmberUrl(payload: string, params: Record<string, string>): string { function buildAmberUrl(payload: string, params: Record<string, string>): string {
const query = Object.entries(params) const extras = Object.entries(params)
.map(([k, v]) => { .map(([k, v]) => `S.${k}=${v}`)
// callbackUrl must be encoded so Amber treats it as a single value .join(';');
if (k === 'callbackUrl') return `${k}=${encodeURIComponent(v)}`; return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`;
return `${k}=${v}`;
})
.join('&');
return `nostrsigner:${payload}?${query}`;
} }
/** /**
@ -339,9 +337,9 @@ function buildAmberUrl(payload: string, params: Record<string, string>): string
*/ */
export function openAmberForLoginNoCallback(): void { export function openAmberForLoginNoCallback(): void {
const amberUrl = buildAmberUrl('', { const amberUrl = buildAmberUrl('', {
type: 'get_public_key',
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'get_public_key',
}); });
openAmberUrl(amberUrl); openAmberUrl(amberUrl);
} }
@ -368,11 +366,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);
// NIP-55 nostrsigner: URL — Amber opens, user approves, Amber calls callbackUrl // Android intent URL for Amber — callbackUrl is RAW (not encoded) per Amber README
const amberUrl = buildAmberUrl('', { const amberUrl = buildAmberUrl('', {
type: 'get_public_key',
compressionType: 'none', compressionType: 'none',
returnType: 'signature', returnType: 'signature',
type: 'get_public_key',
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
}); });

View file

@ -28,21 +28,22 @@ import '@fontsource/barlow-condensed/700.css';
// Leaflet CSS is loaded via CDN in index.html // Leaflet CSS is loaded via CDN in index.html
/** /**
* AMBER CALLBACK PRE-CHECK * AMBER CALLBACK PRE-CHECK + REDIRECT
* *
* PWAs can sometimes lose URL parameters during React hydration. * Amber opens the callbackUrl in a browser tab. Because we use HashRouter,
* This runs BEFORE React renders to capture Amber callback data. * the callbackUrl has no # fragment — it's a plain path:
* We store it in sessionStorage so the AmberCallbackHandler can retrieve it. * https://skate-spots.org/amber-callback?event=<pubkey>
* *
* Handles both: * This IIFE runs before React mounts and:
* - Normal query string: ?event=XXX (PWA / browser) * 1. Captures ?event= from the URL into sessionStorage
* - HashRouter fragment: #/amber-callback?event=XXX (Capacitor APK) * 2. If we're on /amber-callback (non-hash path), redirects to /#/amber-callback
* so HashRouter can match the route and render AmberCallbackPage
*/ */
(function captureAmberCallback() { (function captureAmberCallback() {
// Try normal query string first // Try normal query string first (Amber callback: /amber-callback?event=XXX)
let eventParam = new URLSearchParams(window.location.search).get('event'); let eventParam = new URLSearchParams(window.location.search).get('event');
// Try hash fragment (HashRouter + Capacitor deep link) // Try hash fragment (Capacitor deep link: #/amber-callback?event=XXX)
if (!eventParam) { if (!eventParam) {
const hash = window.location.hash; const hash = window.location.hash;
const qIdx = hash.indexOf('?'); const qIdx = hash.indexOf('?');
@ -52,7 +53,7 @@ import '@fontsource/barlow-condensed/700.css';
} }
if (eventParam) { if (eventParam) {
console.log('[Amber Pre-Check] Detected Amber callback in URL'); console.log('[Amber Pre-Check] Detected Amber callback, event:', eventParam.substring(0, 16) + '...');
try { try {
sessionStorage.setItem('amber_callback_event', eventParam); sessionStorage.setItem('amber_callback_event', eventParam);
sessionStorage.setItem('amber_callback_url', window.location.href); sessionStorage.setItem('amber_callback_url', window.location.href);
@ -60,6 +61,17 @@ import '@fontsource/barlow-condensed/700.css';
} catch (e) { } catch (e) {
console.error('[Amber Pre-Check] Failed to store callback:', e); console.error('[Amber Pre-Check] Failed to store callback:', e);
} }
// If we landed on /amber-callback (non-hash path), redirect into HashRouter
// so React Router can match the route and render AmberCallbackPage.
// The event is already saved in sessionStorage above so it survives the redirect.
const isHashRoute = window.location.hash.startsWith('#/');
if (!isHashRoute && window.location.pathname.includes('amber-callback')) {
console.log('[Amber Pre-Check] Redirecting to HashRouter route: /#/amber-callback');
window.location.replace(window.location.origin + '/#/amber-callback');
// Stop — the page will reload at the correct hash route
return;
}
} }
})(); })();