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:
parent
6f303d1956
commit
712b871e1a
4 changed files with 58 additions and 46 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
32
src/main.tsx
32
src/main.tsx
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue