Merge pull request #2256 from PR0M3TH3AN/codex/add-signer-readiness-gate-for-login-sync

Unify signer-readiness gate across login list sync orchestration
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-08 14:31:30 -05:00
commit 2432e2b45b
3 files changed
+254 -15

No files matched your search

+175 -15
View File
@@ -42,8 +42,97 @@ export function createAuthSessionCoordinator(deps) {
getActiveProfilePubkey,
} = deps;
const now =
typeof performance !== "undefined" && typeof performance.now === "function"
? () => performance.now()
: () => Date.now();
const SIGNER_GATE_STATUS = Object.freeze({
READY: "signer-ready",
EXTENSION_UNAVAILABLE: "extension-unavailable",
PERMISSION_DENIED: "permission-denied",
});
const classifySignerGateError = (error) => {
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim().toLowerCase()
: "";
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim().toLowerCase()
: "";
if (
code.includes("permission") ||
message.includes("permission") ||
message.includes("denied")
) {
return SIGNER_GATE_STATUS.PERMISSION_DENIED;
}
return SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE;
};
const evaluateSignerReadinessGate = async (pubkey) => {
if (!pubkey) {
return {
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
};
}
try {
const signer = await nostrClient.ensureActiveSignerForPubkey(pubkey);
if (!signer) {
return {
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
};
}
if (
signer?.type === "extension" ||
signer?.type === "nip07"
) {
const permissionResult = await nostrClient.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error("permission-denied");
return {
ready: false,
status: classifySignerGateError(error),
error,
};
}
}
return {
ready: true,
status: SIGNER_GATE_STATUS.READY,
error: null,
};
} catch (error) {
return {
ready: false,
status: classifySignerGateError(error),
error,
};
}
};
return {
async handleAuthLogin(detail = {}) {
const authLoginStart = now();
userLogger.info("[auth-login-start]", {
pubkey: detail?.pubkey || this.pubkey || null,
});
const postLoginPromise =
detail && typeof detail.postLoginPromise?.then === "function"
? detail.postLoginPromise
@@ -193,27 +282,71 @@ export function createAuthSessionCoordinator(deps) {
return null;
});
// PRE-AUTH: Request full permissions once so subsequent services don't
// spam individual prompts. This runs immediately (not gated behind
// relaysReadyPromise) so the user can approve while relays connect.
let preAuthSucceeded = false;
const permissionPromise = activePubkey
// Shared signer readiness gate used by both auto-login and manual NIP-07
// login sync orchestration before decrypt-dependent list fetches begin.
const signerReadinessPromise = activePubkey
? Promise.resolve()
.then(async () => {
const signer = await nostrClient.ensureActiveSignerForPubkey(
activePubkey,
const signerGateStart = now();
let outcome = await evaluateSignerReadinessGate(activePubkey);
if (!outcome.ready) {
outcome = {
...outcome,
recoveryAttempted: true,
initialStatus: outcome.status,
};
const recoveryOutcome = await evaluateSignerReadinessGate(
activePubkey,
);
outcome = {
...recoveryOutcome,
recoveryAttempted: true,
initialStatus: outcome.initialStatus,
};
}
const durationMs = Math.max(
0,
Math.round(now() - signerGateStart),
);
if (signer?.type === "extension" || signer?.type === "nip07") {
await nostrClient.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
const finalOutcome = {
...outcome,
recoveryAttempted: Boolean(outcome.recoveryAttempted),
durationMs,
};
userLogger.info("[signer-ready]", {
ready: finalOutcome.ready,
status: finalOutcome.status,
recoveryAttempted: finalOutcome.recoveryAttempted,
durationMs: finalOutcome.durationMs,
});
if (!finalOutcome.ready && finalOutcome.error) {
devLogger.warn(
"[Application] Signer readiness gate not ready after bounded recovery:",
finalOutcome.error,
);
}
preAuthSucceeded = true;
return finalOutcome;
})
.catch((error) => {
devLogger.warn("[Application] Pre-authorization failed:", error);
devLogger.warn("[Application] Signer readiness gate failed:", error);
return {
ready: false,
status: classifySignerGateError(error),
error,
recoveryAttempted: true,
durationMs: 0,
};
})
: Promise.resolve();
: Promise.resolve({
ready: false,
status: SIGNER_GATE_STATUS.EXTENSION_UNAVAILABLE,
error: null,
recoveryAttempted: false,
durationMs: 0,
});
// Start list loading as soon as relays are ready — lists don't depend on
// profile data, only on relay URLs. This runs in parallel with profile
@@ -237,8 +370,17 @@ export function createAuthSessionCoordinator(deps) {
// If pre-auth fails (e.g. extension was slow to inject), allow list
// services to request permissions themselves so they don't stall waiting
// for a retry timer.
const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => {
const allowPermissionPrompt = !preAuthSucceeded;
const listStatePromise = Promise.all([
relaysReadyPromise,
signerReadinessPromise,
]).then(async ([, signerGateOutcome]) => {
const listSyncStart = now();
const allowPermissionPrompt = !signerGateOutcome?.ready;
userLogger.info("[lists-sync-start]", {
pubkey: activePubkey || null,
allowPermissionPrompt,
signerStatus: signerGateOutcome?.status || null,
});
const hasValidBlocksCache = () => {
if (!activePubkey || !userBlocks) {
return false;
@@ -296,6 +438,7 @@ export function createAuthSessionCoordinator(deps) {
async () => {
const loaded = await this.authService.loadBlocksForPubkey(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const ok = loaded !== false;
if (!ok) {
@@ -321,6 +464,7 @@ export function createAuthSessionCoordinator(deps) {
async () => {
await subscriptions.ensureLoaded(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const error = subscriptions?.lastLoadError || null;
this.capturePermissionPromptFromError(error);
@@ -342,6 +486,7 @@ export function createAuthSessionCoordinator(deps) {
async () => {
await this.hashtagPreferences.load(activePubkey, {
allowPermissionPrompt,
signerReadinessGate: signerGateOutcome,
});
const error = this.hashtagPreferences?.lastLoadError || null;
this.capturePermissionPromptFromError(error);
@@ -386,12 +531,15 @@ export function createAuthSessionCoordinator(deps) {
Promise.all([
this.authService?.loadBlocksForPubkey?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
subscriptions?.ensureLoaded?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
this.hashtagPreferences?.load?.(activePubkey, {
allowPermissionPrompt: true,
signerReadinessGate: signerGateOutcome,
}),
]),
)
@@ -450,6 +598,13 @@ export function createAuthSessionCoordinator(deps) {
}
const listsState = ready ? (degraded ? "degraded" : "ready") : "error";
userLogger.info("[lists-sync-complete]", {
pubkey: activePubkey || null,
ready,
degraded,
fatal,
durationMs: Math.max(0, Math.round(now() - listSyncStart)),
});
this.updateAuthLoadingState({
lists: listsState,
listsDetail: listSyncDetail,
@@ -594,6 +749,11 @@ export function createAuthSessionCoordinator(deps) {
);
});
}
userLogger.info("[auth-login-complete]", {
pubkey: activePubkey || null,
durationMs: Math.max(0, Math.round(now() - authLoginStart)),
});
},
handleBlocksLoaded(detail = {}) {
+37
View File
@@ -578,6 +578,14 @@ class HashtagPreferencesService {
async loadInternal(pubkey, options = {}) {
const normalized = normalizeHexPubkey(pubkey);
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const shouldSuppressPermissionEscalation =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
this.lastAttemptAt = Date.now();
this.lastLoadError = null;
let wasLoadedForUser =
@@ -828,6 +836,7 @@ class HashtagPreferencesService {
try {
const decryptPromise = this.decryptEvent(latest, normalized, {
allowPermissionPrompt,
signerReadinessGate,
});
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
@@ -859,6 +868,7 @@ class HashtagPreferencesService {
if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") {
this.scheduleDecryptRetry(normalized, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
@@ -866,8 +876,20 @@ class HashtagPreferencesService {
!allowPermissionPrompt &&
decryptResult.error?.code === "hashtag-preferences-permission-required"
) {
if (shouldSuppressPermissionEscalation) {
userLogger.warn(
`${LOG_PREFIX} Skipping permission escalation due to shared signer gate outcome.`,
signerReadinessGate,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.scheduleDecryptRetry(normalized, decryptResult.error, {
allowPermissionPrompt: true,
signerReadinessGate,
});
if (wasBackgroundLoading) {
this.backgroundLoading = false;
@@ -953,6 +975,14 @@ class HashtagPreferencesService {
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const suppressPermissionPrompt =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
let signer = getActiveSigner();
if (
!signer &&
@@ -979,6 +1009,13 @@ class HashtagPreferencesService {
error.code = "hashtag-preferences-permission-required";
return { ok: false, error };
}
if (suppressPermissionPrompt) {
const error = new Error(
"Shared signer gate indicates extension permissions are unavailable.",
);
error.code = "hashtag-preferences-permission-required";
return { ok: false, error };
}
try {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
+42
View File
@@ -479,6 +479,14 @@ class SubscriptionsManager {
try {
this.lastAttemptAt = Date.now();
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const shouldSuppressPermissionEscalation =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
const wasBackgroundLoading = this.backgroundLoading;
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const wasLoadedForUser =
@@ -636,6 +644,7 @@ class SubscriptionsManager {
try {
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey, {
allowPermissionPrompt,
signerReadinessGate,
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
@@ -660,6 +669,7 @@ class SubscriptionsManager {
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
@@ -667,10 +677,25 @@ class SubscriptionsManager {
!allowPermissionPrompt &&
decryptResult.error?.code === "subscriptions-permission-required"
) {
if (shouldSuppressPermissionEscalation) {
userLogger.warn(
"[SubscriptionsManager] Skipping permission escalation due to shared signer gate outcome.",
signerReadinessGate,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
// Retry with permission prompt enabled so the signer / window.nostr
// path is available once the extension has finished initializing.
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt: true,
signerReadinessGate,
});
if (wasBackgroundLoading) {
this.backgroundLoading = false;
@@ -698,6 +723,7 @@ class SubscriptionsManager {
);
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
@@ -710,6 +736,7 @@ class SubscriptionsManager {
}
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
signerReadinessGate,
});
return;
}
@@ -959,6 +986,14 @@ class SubscriptionsManager {
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const signerReadinessGate =
options?.signerReadinessGate && typeof options.signerReadinessGate === "object"
? options.signerReadinessGate
: null;
const suppressPermissionPrompt =
signerReadinessGate &&
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44;
@@ -1008,6 +1043,13 @@ class SubscriptionsManager {
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
if (suppressPermissionPrompt) {
const error = new Error(
"Shared signer gate indicates extension permissions are unavailable.",
);
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
try {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,