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",
"private": true,
"version": "1.0.29",
"version": "1.0.30",
"type": "module",
"homepage": "https://skate-spots.com",
"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"
- Reverted to clean nostrsigner: format per NIP-55 spec with only callbackUrl encoded once
- Switched to correct Android intent: URL format per Amber README
- 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 {
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
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=';
}
// HashRouter: routes live in the # fragment, so the callback URL must be:
// https://skate-spots.org/#/amber-callback?event=<result>
// This is what Amber will open in the browser tab after user approves.
return `${window.location.origin}/#/amber-callback?event=`;
// IMPORTANT: The callbackUrl is embedded RAW inside an intent: string that uses
// # as a delimiter (intent:<payload>#Intent;...). So the callbackUrl CANNOT
// contain a # character — it would break the intent format.
//
// 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.
*
* 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=
* Navigate to an Amber intent URL.
*/
function openAmberUrl(url: string): void {
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:
* nostrsigner:<payload>?type=<method>&compressionType=none&returnType=signature&callbackUrl=<encoded>
* Per Amber README (https://github.com/greenart7c3/Amber) web apps section,
* the exact format is:
*
* The callbackUrl is the ONLY value that needs encodeURIComponent.
* All other params are plain query string values.
* intent:<payload>#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://example.com/?event=;end
*
* 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 {
const query = Object.entries(params)
.map(([k, v]) => {
// callbackUrl must be encoded so Amber treats it as a single value
if (k === 'callbackUrl') return `${k}=${encodeURIComponent(v)}`;
return `${k}=${v}`;
})
.join('&');
return `nostrsigner:${payload}?${query}`;
const extras = Object.entries(params)
.map(([k, v]) => `S.${k}=${v}`)
.join(';');
return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`;
}
/**
@ -339,9 +337,9 @@ function buildAmberUrl(payload: string, params: Record<string, string>): string
*/
export function openAmberForLoginNoCallback(): void {
const amberUrl = buildAmberUrl('', {
type: 'get_public_key',
compressionType: 'none',
returnType: 'signature',
type: 'get_public_key',
});
openAmberUrl(amberUrl);
}
@ -368,11 +366,11 @@ export function openAmberForLogin(): void {
const currentPath = window.location.hash.replace(/^#/, '') || '/';
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('', {
type: 'get_public_key',
compressionType: 'none',
returnType: 'signature',
type: 'get_public_key',
callbackUrl: callbackUrl,
});

View file

@ -28,21 +28,22 @@ import '@fontsource/barlow-condensed/700.css';
// 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.
* This runs BEFORE React renders to capture Amber callback data.
* We store it in sessionStorage so the AmberCallbackHandler can retrieve it.
* Amber opens the callbackUrl in a browser tab. Because we use HashRouter,
* the callbackUrl has no # fragment — it's a plain path:
* https://skate-spots.org/amber-callback?event=<pubkey>
*
* Handles both:
* - Normal query string: ?event=XXX (PWA / browser)
* - HashRouter fragment: #/amber-callback?event=XXX (Capacitor APK)
* This IIFE runs before React mounts and:
* 1. Captures ?event= from the URL into sessionStorage
* 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() {
// 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');
// Try hash fragment (HashRouter + Capacitor deep link)
// Try hash fragment (Capacitor deep link: #/amber-callback?event=XXX)
if (!eventParam) {
const hash = window.location.hash;
const qIdx = hash.indexOf('?');
@ -52,7 +53,7 @@ import '@fontsource/barlow-condensed/700.css';
}
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 {
sessionStorage.setItem('amber_callback_event', eventParam);
sessionStorage.setItem('amber_callback_url', window.location.href);
@ -60,6 +61,17 @@ import '@fontsource/barlow-condensed/700.css';
} catch (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;
}
}
})();