mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
3 files changed
+254
-15
No files matched your search
@@ -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 = {}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user