uploads: locked-storage file picks unlock inline instead of dead-ending (#56)

All three paths (upload video pick, upload thumbnail pick, edit "Replace
file") now route through the shared signer gate — silent kept-unlocked
restore, else ONE passphrase prompt — then unlock storage and continue the
same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick
restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js
(file-size budget); ensureSigner threaded via initUploadModal/initEditModal.

Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-03 21:01:20 -04:00
1 parent 7ead34378e
commit e32298cc89
7 files changed
+496 -43

No files matched your search

+4
View File
@@ -25,6 +25,7 @@ export class EditModal {
storageService = null,
getCurrentPubkey = null,
safeEncodeNpub = null,
ensureSigner = null,
eventTarget,
container,
} = {}) {
@@ -39,6 +40,9 @@ export class EditModal {
this.storageService = storageService || null;
this.getCurrentPubkey =
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
// inline (cached keep-unlocked key or one passphrase prompt).
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
this.mediaUploader = new MediaUploader({
r2Service,
s3Service,
+39 -41
View File
@@ -27,6 +27,11 @@ import {
} from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
import {
ensureStorageUnlockedForUpload,
resetThumbnailPicker,
promptStoredNsecUnlock,
} from "./uploadModalStorageUnlock.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -55,6 +60,7 @@ export class UploadModal {
container,
onRequestStorageSettings,
onRequestUnlock,
ensureSigner,
getHashtagSuggestions,
} = {}) {
this.authService = authService || null;
@@ -96,6 +102,10 @@ export class UploadModal {
// Opens the login modal's unlock-saved-key (passphrase) flow — used when a
// persisted nsec session is locked after a reload.
this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : null;
// Shared signer gate (app.ensureEncryptionCapableSigner): silently restores a
// kept-unlocked key or shows ONE passphrase prompt, so the file pickers can
// unlock storage inline instead of dead-ending (#56).
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
this.root = null;
this.isVisible = false;
@@ -530,9 +540,13 @@ export class UploadModal {
return;
}
if (!this.isStorageUnlocked) {
this.showError("Please unlock storage before selecting a file.");
e.target.value = "";
return;
// #56: unlock inline (silent restore or one passphrase prompt) and
// continue with this same file pick instead of erroring.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
e.target.value = "";
return;
}
}
// Start new session
@@ -628,12 +642,19 @@ export class UploadModal {
}
async handleThumbnailSelection(file) {
if (!this.storageConfigured || !this.isStorageUnlocked) {
// We can't upload yet. Just hold it in state or warn?
// Since UI disables the browse button until unlocked (mostly), we assume safe.
// But if they just unlocked, we're good.
if (!this.storageConfigured) {
this.showError("Configure storage before uploading a thumbnail.");
this.resetThumbnailPicker();
return;
}
if (!this.isStorageUnlocked) {
// #56: prompt to unlock inline instead of silently dropping the pick.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
this.resetThumbnailPicker();
return;
}
}
const currentUploadId = ++this.thumbnailUploadId;
@@ -1011,41 +1032,18 @@ export class UploadModal {
}
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can stop.
// #36 / #56 storage-unlock glue lives in uploadModalStorageUnlock.js (file-size
// budget); these stay as instance methods so callers/tests are unchanged.
async ensureStorageUnlockedForUpload() {
return ensureStorageUnlockedForUpload(this);
}
resetThumbnailPicker() {
resetThumbnailPicker(this);
}
promptStoredNsecUnlock(pubkey) {
const client = this.authService?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return false;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (err) {
return false;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return false;
}
const metaPubkey =
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
const activePubkey =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
return false;
}
if (typeof this.onRequestUnlock !== "function") {
return false;
}
this.showError(
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
);
try {
this.onRequestUnlock();
} catch (err) {
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
}
return true;
return promptStoredNsecUnlock(this, pubkey);
}
updateLockUi() {
+88 -2
View File
@@ -7,6 +7,10 @@
// and its rendered root (`context`).
import { devLogger, userLogger } from "../../utils/logger.js";
import {
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
// Set a field's value AND mark it edited/unlocked so EditModal.collect treats it
// as a user change (isEditing() reads readOnly===false || dataset.isEditing).
@@ -24,7 +28,8 @@ function setStatus(el, text) {
// Resolve the active storage connection and surface a user-facing reason when
// upload isn't possible. Returns the connection on success, null otherwise.
async function ensureUploadable(modal) {
// `deps` are injectable for tests only.
export async function ensureUploadable(modal, deps = {}) {
if (!modal.mediaUploader) {
modal.showError("Uploads are unavailable in this view.");
return null;
@@ -40,7 +45,88 @@ async function ensureUploadable(modal) {
return null;
}
if (!conn.unlocked || !conn.credentials) {
modal.showError("Unlock your storage in the profile modal, then try again.");
// #56: unlock inline (kept-unlocked restore or one passphrase prompt) and
// continue with this same file pick instead of bouncing the user to the
// profile modal.
return tryInlineStorageUnlock(modal, deps);
}
return conn;
}
const UNLOCK_FALLBACK_MESSAGE =
"Unlock your storage in the profile modal, then try again.";
async function tryInlineStorageUnlock(modal, deps = {}) {
const getSigner =
typeof deps.getSigner === "function" ? deps.getSigner : getActiveSigner;
const requestPermissions =
typeof deps.requestPermissions === "function"
? deps.requestPermissions
: requestDefaultExtensionPermissions;
const pubkey =
typeof modal.getCurrentPubkey === "function" ? modal.getCurrentPubkey() : null;
if (!pubkey || !modal.storageService) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
if (typeof modal.ensureSigner === "function") {
let gate = null;
try {
gate = await modal.ensureSigner({
pubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock storage for this upload.",
});
} catch (error) {
devLogger.warn("[editModalUpload] Signer gate failed:", error);
}
if (gate && gate.ok !== true) {
// Cancel aborts quietly; a bad passphrase already showed its own toast.
if (gate.reason !== "cancelled" && gate.reason !== "bad-passphrase") {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
}
return null;
}
}
const signer = getSigner();
if (
!signer ||
(typeof signer.nip44Decrypt !== "function" &&
typeof signer.nip04Decrypt !== "function")
) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
try {
if (signer.type === "extension" || signer.type === "nip07") {
const permissionResult = await requestPermissions();
if (!permissionResult?.ok) {
modal.showError("Extension permissions are required to unlock storage.");
return null;
}
}
await modal.storageService.unlock(pubkey, { signer });
} catch (error) {
userLogger.error("[editModalUpload] Storage unlock failed:", error);
modal.showError(
"Failed to unlock storage: " + (error?.message || String(error)),
);
return null;
}
let conn = null;
try {
conn = await modal.mediaUploader.resolveActiveConnection();
} catch (error) {
devLogger.warn("[editModalUpload] Failed to re-resolve storage connection:", error);
}
if (!conn?.unlocked || !conn?.credentials) {
modal.showError(UNLOCK_FALLBACK_MESSAGE);
return null;
}
return conn;
@@ -0,0 +1,106 @@
// js/ui/components/uploadModalStorageUnlock.js
//
// Storage-unlock glue for the Upload modal (#36 / #56), kept out of
// UploadModal.js to stay within its file-size budget. Every function operates
// on the UploadModal instance (`modal`); UploadModal exposes them as thin
// prototype delegates so callers and tests keep using the instance methods.
import { devLogger, userLogger } from "../../utils/logger.js";
// #56: the file pickers unlock storage inline instead of dead-ending. The
// shared signer gate (app.ensureEncryptionCapableSigner) restores a cached
// keep-unlocked key silently or shows ONE passphrase prompt; handleUnlock
// then unlocks storage with the restored signer (extension permissions +
// credential reload included). Returns true when the upload can proceed.
// Cancel / bad-passphrase abort quietly (the gate surfaces its own toast for
// a wrong passphrase); other gate failures fall through to handleUnlock so
// its existing messaging (saved-key flow / "no signer") still applies.
export async function ensureStorageUnlockedForUpload(modal) {
if (!modal.storageService) return false;
const pubkey = modal.getCurrentPubkey ? modal.getCurrentPubkey() : null;
if (!pubkey) return false;
if (!modal.storageService.isUnlocked(pubkey)) {
if (typeof modal.ensureSigner === "function") {
let gate = null;
try {
gate = await modal.ensureSigner({
pubkey,
need: "encrypt",
promptMessage:
"Re-enter your PIN / passphrase to unlock storage for this upload.",
});
} catch (error) {
devLogger?.warn?.("[UploadModal] Signer gate failed:", error);
}
if (
gate &&
gate.ok !== true &&
(gate.reason === "cancelled" || gate.reason === "bad-passphrase")
) {
return false;
}
}
await modal.handleUnlock();
}
const unlocked = modal.storageService.isUnlocked(pubkey);
if (unlocked && (!modal.isStorageUnlocked || !modal.activeCredentials)) {
modal.isStorageUnlocked = true;
modal.updateLockUi();
await modal.loadFromStorage();
}
modal.isStorageUnlocked = unlocked;
return unlocked;
}
// Undo the picker-side effects of a thumbnail selection that couldn't upload
// (placeholder shows the picked filename and the URL input is disabled).
export function resetThumbnailPicker(modal) {
if (modal.inputs?.thumbnailFile) {
modal.inputs.thumbnailFile.value = "";
}
const thumbnail = modal.inputs?.thumbnail;
if (thumbnail) {
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
thumbnail.disabled = false;
}
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can
// stop instead of dead-ending with "No signer available".
export function promptStoredNsecUnlock(modal, pubkey) {
const client = modal.authService?.nostrClient;
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
return false;
}
let meta = null;
try {
meta = client.getStoredSessionActorMetadata();
} catch (err) {
return false;
}
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
return false;
}
const metaPubkey =
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
const activePubkey =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
return false;
}
if (typeof modal.onRequestUnlock !== "function") {
return false;
}
modal.showError(
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
);
try {
modal.onRequestUnlock();
} catch (err) {
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
}
return true;
}
+6
View File
@@ -51,6 +51,12 @@ export function initEditModal({
storageService,
getCurrentPubkey,
safeEncodeNpub,
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
// inline (cached keep-unlocked key or one passphrase prompt).
ensureSigner: (options) =>
typeof app?.ensureEncryptionCapableSigner === "function"
? app.ensureEncryptionCapableSigner(options)
: null,
eventTarget: events,
container,
});
+6
View File
@@ -61,6 +61,12 @@ export function initUploadModal({
app.profileController.show("storage");
}
},
// Shared signer gate (#56): restores a kept-unlocked key silently or shows
// one passphrase prompt so the file pickers can unlock storage inline.
ensureSigner: (options) =>
typeof app?.ensureEncryptionCapableSigner === "function"
? app.ensureEncryptionCapableSigner(options)
: null,
// Open the login modal's unlock-saved-key (passphrase) flow when a persisted
// nsec session is locked after a reload.
onRequestUnlock: () => {
@@ -0,0 +1,247 @@
// #56: when storage is locked, the upload modal's file pickers and the edit
// modal's "Replace file" pickers must unlock inline — silent kept-unlocked
// restore or ONE passphrase prompt via the shared signer gate — and continue
// with the same file pick, instead of dead-ending ("Please unlock storage…",
// a silent no-op, or "Unlock your storage in the profile modal").
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-upload-picker-inline-unlock
// given: "storage locked for the active pubkey; a signer gate that succeeds / is cancelled / fails"
// when: "ensureStorageUnlockedForUpload / handleThumbnailSelection run"
// then: "gate ok -> storage unlocked and pick proceeds; cancel/bad-passphrase -> quiet abort; other failures fall through to the regular unlock messaging"
// - id: SCN-edit-replace-file-inline-unlock
// given: "the edit modal's resolved storage connection is locked"
// when: "ensureUploadable runs"
// then: "the gate + storageService.unlock run and the re-resolved unlocked connection is returned; cancel aborts without unlocking"
// observable_outcomes:
// - "storageService.unlock called exactly when the gate allows it"
// - "cancelled gate -> no unlock attempt, no error toast"
// - "thumbnail picker state reset when the unlock is refused"
// determinism_controls:
// - "pure fake ctx objects; no DOM, no timers, no network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { UploadModal } from "../js/ui/components/UploadModal.js";
import { ensureUploadable } from "../js/ui/components/editModalUpload.js";
const PUBKEY = "a".repeat(64);
const ensureUnlocked = UploadModal.prototype.ensureStorageUnlockedForUpload;
const handleThumb = UploadModal.prototype.handleThumbnailSelection;
// --- UploadModal.ensureStorageUnlockedForUpload ---
function makeUploadCtx({ unlockedAfterHandleUnlock = true, gate } = {}) {
const calls = { handleUnlock: 0, loadFromStorage: 0, errors: [], gates: [] };
let unlocked = false;
const ctx = {
getCurrentPubkey: () => PUBKEY,
storageService: { isUnlocked: () => unlocked },
ensureSigner:
gate === undefined
? null
: async (options) => {
calls.gates.push(options);
return gate;
},
handleUnlock: async () => {
calls.handleUnlock += 1;
if (unlockedAfterHandleUnlock) unlocked = true;
},
updateLockUi: () => {},
loadFromStorage: async () => {
calls.loadFromStorage += 1;
ctx.activeCredentials = { accessKeyId: "k" };
},
showError: (m) => calls.errors.push(m),
isStorageUnlocked: false,
activeCredentials: null,
};
return { ctx, calls, setUnlocked: (v) => (unlocked = v) };
}
test("upload picker: gate ok -> unlocks via handleUnlock and proceeds", async () => {
const { ctx, calls } = makeUploadCtx({ gate: { ok: true } });
const result = await ensureUnlocked.call(ctx);
assert.equal(result, true);
assert.equal(calls.gates.length, 1, "signer gate consulted once");
assert.equal(calls.gates[0].need, "encrypt");
assert.equal(calls.gates[0].pubkey, PUBKEY);
assert.equal(calls.handleUnlock, 1, "storage unlock ran after the gate");
assert.equal(ctx.isStorageUnlocked, true);
assert.equal(calls.errors.length, 0);
});
test("upload picker: cancelled gate aborts quietly (no unlock, no toast)", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "cancelled" },
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 0, "no unlock attempt after cancel");
assert.equal(calls.errors.length, 0, "cancel is silent");
assert.equal(ctx.isStorageUnlocked, false);
});
test("upload picker: bad passphrase aborts without a second toast", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "bad-passphrase" },
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 0);
assert.equal(calls.errors.length, 0, "the gate already showed its own toast");
});
test("upload picker: non-user gate failures fall through to handleUnlock's messaging", async () => {
const { ctx, calls } = makeUploadCtx({
gate: { ok: false, reason: "no-stored-key" },
unlockedAfterHandleUnlock: false,
});
assert.equal(await ensureUnlocked.call(ctx), false);
assert.equal(calls.handleUnlock, 1, "regular unlock path still runs");
});
test("upload picker: already-unlocked storage loads credentials without any gate", async () => {
const { ctx, calls, setUnlocked } = makeUploadCtx({ gate: { ok: true } });
setUnlocked(true);
assert.equal(await ensureUnlocked.call(ctx), true);
assert.equal(calls.gates.length, 0, "no prompt when storage is already unlocked");
assert.equal(calls.handleUnlock, 0);
assert.equal(calls.loadFromStorage, 1, "stale credentials refreshed");
assert.ok(ctx.activeCredentials);
});
test("thumbnail pick while locked: refused unlock resets the picker, no upload", async () => {
const calls = { reset: 0, uploads: 0 };
const ctx = {
storageConfigured: true,
isStorageUnlocked: false,
ensureStorageUnlockedForUpload: async () => false,
resetThumbnailPicker: () => {
calls.reset += 1;
},
mediaUploader: {
uploadThumbnail: async () => {
calls.uploads += 1;
return { url: "https://x/thumb.jpg" };
},
},
showError: () => {},
};
await handleThumb.call(ctx, { name: "t.jpg" });
assert.equal(calls.reset, 1, "picker state restored");
assert.equal(calls.uploads, 0, "upload never started");
});
// --- editModalUpload.ensureUploadable (Edit modal "Replace file") ---
function makeEditCtx({ gate, unlockThrows = false } = {}) {
const calls = { unlock: [], errors: [], gates: [], resolves: 0 };
let unlocked = false;
const conn = () => ({
configured: true,
unlocked,
credentials: unlocked ? { accessKeyId: "k" } : null,
provider: "s3",
});
const modal = {
mediaUploader: {
resolveActiveConnection: async () => {
calls.resolves += 1;
return conn();
},
},
storageService: {
unlock: async (pubkey, opts) => {
calls.unlock.push({ pubkey, signer: opts?.signer });
if (unlockThrows) throw new Error("boom");
unlocked = true;
},
},
getCurrentPubkey: () => PUBKEY,
ensureSigner:
gate === undefined
? null
: async (options) => {
calls.gates.push(options);
return gate;
},
showError: (m) => calls.errors.push(m),
};
return { modal, calls };
}
const capableSigner = { nip44Decrypt: () => "x" };
test("edit replace-file: locked connection unlocks inline and returns the live connection", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.ok(conn, "connection returned");
assert.equal(conn.unlocked, true);
assert.ok(conn.credentials, "credentials present after inline unlock");
assert.equal(calls.gates.length, 1, "signer gate consulted");
assert.deepEqual(calls.unlock[0].pubkey, PUBKEY);
assert.equal(calls.errors.length, 0);
assert.equal(calls.resolves, 2, "connection re-resolved after the unlock");
});
test("edit replace-file: cancelled gate aborts without touching storage", async () => {
const { modal, calls } = makeEditCtx({
gate: { ok: false, reason: "cancelled" },
});
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.equal(conn, null);
assert.equal(calls.unlock.length, 0, "no unlock attempt after cancel");
assert.equal(calls.errors.length, 0, "cancel is silent");
});
test("edit replace-file: no gate + no capable signer keeps the actionable profile-modal message", async () => {
const { modal, calls } = makeEditCtx({});
const conn = await ensureUploadable(modal, { getSigner: () => null });
assert.equal(conn, null);
assert.equal(calls.unlock.length, 0);
assert.equal(calls.errors.length, 1);
assert.match(calls.errors[0], /profile modal/i);
});
test("edit replace-file: extension signer requests permissions before unlocking", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
let permissionAsks = 0;
const conn = await ensureUploadable(modal, {
getSigner: () => ({ type: "extension", nip04Decrypt: () => "x" }),
requestPermissions: async () => {
permissionAsks += 1;
return { ok: true };
},
});
assert.ok(conn);
assert.equal(permissionAsks, 1, "permissions requested for extension signers");
assert.equal(calls.unlock.length, 1);
});
test("edit replace-file: unlock failure surfaces the error and returns null", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true }, unlockThrows: true });
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.equal(conn, null);
assert.equal(calls.errors.length, 1);
assert.match(calls.errors[0], /Failed to unlock storage/);
});
test("edit replace-file: an already-unlocked connection passes straight through", async () => {
const { modal, calls } = makeEditCtx({ gate: { ok: true } });
modal.mediaUploader.resolveActiveConnection = async () => ({
configured: true,
unlocked: true,
credentials: { accessKeyId: "k" },
provider: "s3",
});
const conn = await ensureUploadable(modal, { getSigner: () => capableSigner });
assert.ok(conn);
assert.equal(calls.gates.length, 0, "no prompt when already unlocked");
assert.equal(calls.unlock.length, 0);
});