mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-28 01:00:41 +00:00
Make purchase vault durable across devices / domains
Fix three load-bearing gaps in the premium-templates vault that together made paid unlocks invisible on any session other than the one where the purchase happened (e.g. localhost vs. artstr.org with the same login). 1) Receipt fast-path now re-derives and decrypts the payload. The soft-gate KDF is deterministic from public material + the static client pepper, so a valid kind-9735 receipt is sufficient to recover the full decrypted payload locally — no surviving vault required. Previously the fast-path only set unlocked:true without the payload, so a card could appear unlocked but Use / Fork had nothing to fork. Now it runs Premium.decryptPayload on the envelope when a receipt matches by address (or event id), stores the full payload + preview in the local cache, AND opportunistically back-fills the vault so the next device doesn't have to redo the receipt scan. 2) Vault upsert is synchronous-to-success with visible failures. The unlock success path used to fire-and-forget upsert with .catch(console.warn). If the vault publish silently failed — relay rejection, narrowed relay list, etc. — the user still saw "Unlocked ✓" while no other device would ever see the purchase. Now the unlock awaits upsert, shows a "Syncing to your purchase vault…" interim toast, and on failure replaces the success toast with a clear error explaining the unlock is device-local only. upsertPurchaseVaultItem also stops swallowing publish errors so callers see them. 3) Vault traffic uses a wider relay union. New _vaultRelayUnion() returns the user's configured relays unioned with DEFAULT_NOSTR_RELAYS (deduped). _publishVaultEvent, loadPurchaseVault, and relayFastPathUnlockCheck all use it, so vault events stay reachable when a user narrows their relay list or runs the app on a different domain. NIP-65 write relays can be folded in later by editing one helper. Smoke-tested via CDP: - _vaultRelayUnion correctly merges + dedupes user + default relays. - Receipt fast-path now finds a synthetic kind-9735 receipt by `a`, decrypts the matching premium event end-to-end, and writes a cache entry with the full original payload (title, layers, etc). - upsertPurchaseVaultItem propagates publish failures instead of returning null — callers can now react to vault sync errors. Together these mean: an existing purchase will auto-recover the next time the Premium tab loads on any browser / domain that shares the same Nostr login, just from the receipts already on relays. No re-payment, no manual vault recovery.
This commit is contained in:
1 parent
467fbcf5e4
commit
99d83e291a
1 file changed
+140
-20
+140
-20
@@ -5767,7 +5767,10 @@
|
||||
if (!Array.isArray(gatedEvents) || !gatedEvents.length) return;
|
||||
const myPubkey = normalizePubkey(state.community?.myPubkey || '');
|
||||
if (!myPubkey) return;
|
||||
const relays = splitRelayList(state.community?.relays || '');
|
||||
// Query receipts on the wider vault relay union so a session that
|
||||
// narrowed its relay list (or runs on a different domain) can
|
||||
// still recover prior purchases.
|
||||
const relays = _vaultRelayUnion();
|
||||
|
||||
const freshEvents = gatedEvents.filter((e) => !isUnlockedLocally(e.id));
|
||||
if (!freshEvents.length) return;
|
||||
@@ -5797,25 +5800,95 @@
|
||||
}, { timeoutMs: 4000 }).catch(() => []));
|
||||
}
|
||||
const receiptSets = await Promise.all(queries);
|
||||
// Bucket the full receipt events by address / event-id so the
|
||||
// decrypt-and-back-fill step below can attach real evidence to
|
||||
// the vault entry, not just "yes, paid".
|
||||
const receiptsByAddress = new Map();
|
||||
const receiptsByEventId = new Map();
|
||||
const seenAddresses = new Set();
|
||||
const seenEventIds = new Set();
|
||||
for (const receipts of receiptSets) {
|
||||
for (const ev of receipts || []) {
|
||||
const aTag = (ev.tags || []).find((t) => t[0] === 'a' && t[1]);
|
||||
if (aTag) seenAddresses.add(aTag[1]);
|
||||
const eTag = (ev.tags || []).find((t) => t[0] === 'e' && t[1]);
|
||||
if (eTag) seenEventIds.add(eTag[1]);
|
||||
for (const r of receipts || []) {
|
||||
const aTag = (r.tags || []).find((t) => t[0] === 'a' && t[1]);
|
||||
const eTag = (r.tags || []).find((t) => t[0] === 'e' && t[1]);
|
||||
if (aTag) {
|
||||
seenAddresses.add(aTag[1]);
|
||||
const arr = receiptsByAddress.get(aTag[1]) || [];
|
||||
arr.push(r);
|
||||
receiptsByAddress.set(aTag[1], arr);
|
||||
}
|
||||
if (eTag) {
|
||||
seenEventIds.add(eTag[1]);
|
||||
const arr = receiptsByEventId.get(eTag[1]) || [];
|
||||
arr.push(r);
|
||||
receiptsByEventId.set(eTag[1], arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ev of freshEvents) {
|
||||
if (isUnlockedLocally(ev.id)) continue;
|
||||
const addr = addressByEvent.get(ev.id);
|
||||
if (addr && seenAddresses.has(addr)) {
|
||||
markUnlocked(ev.id, { source: 'relay-fastpath-a', address: addr });
|
||||
continue;
|
||||
const matchedByAddress = !!(addr && seenAddresses.has(addr));
|
||||
const matchedByEventId = seenEventIds.has(ev.id);
|
||||
if (!matchedByAddress && !matchedByEventId) continue;
|
||||
|
||||
// The soft-gate KDF is deterministic from public material +
|
||||
// the static client pepper, so a valid receipt is sufficient
|
||||
// to re-derive and decrypt locally — no creator-online step,
|
||||
// no surviving vault required. Run the decrypt now so the
|
||||
// card unlocks fully (payload + preview), not just the flag.
|
||||
let envelope = null;
|
||||
let decryptedPayload = null;
|
||||
try {
|
||||
envelope = JSON.parse(ev.content || '{}');
|
||||
if (isPremiumEvent(ev)) {
|
||||
decryptedPayload = await Premium.decryptPayload(envelope, {
|
||||
creatorPubkey: ev.pubkey,
|
||||
dTag: premiumEventDTag(ev),
|
||||
schemaVersion: envelope?.payloadSchemaVersion || 5,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[zap-gated] fast-path decrypt failed for', ev.id, err);
|
||||
}
|
||||
if (seenEventIds.has(ev.id)) {
|
||||
markUnlocked(ev.id, { source: 'relay-fastpath-e' });
|
||||
|
||||
markUnlocked(ev.id, {
|
||||
source: matchedByAddress ? 'relay-fastpath-a' : 'relay-fastpath-e',
|
||||
address: addr || '',
|
||||
payload: decryptedPayload || undefined,
|
||||
previewJpegDataUrl: envelope?.preview?.dataUrl || undefined,
|
||||
});
|
||||
|
||||
// Opportunistically back-fill the vault so the next device /
|
||||
// domain doesn't have to redo this receipt scan. Failures
|
||||
// here are non-fatal — the local cache still holds the
|
||||
// re-derived payload and the receipts remain on relays.
|
||||
if (decryptedPayload && isPremiumEvent(ev)) {
|
||||
const matchedReceipts =
|
||||
(matchedByAddress ? (receiptsByAddress.get(addr) || []) : (receiptsByEventId.get(ev.id) || []));
|
||||
const totalSats = matchedReceipts.reduce((sum, r) => {
|
||||
const amtTag = (r.tags || []).find((t) => t[0] === 'amount' && t[1]);
|
||||
const msats = amtTag ? Number(amtTag[1]) || 0 : 0;
|
||||
return sum + Math.round(msats / 1000);
|
||||
}, 0);
|
||||
const vaultItem = {
|
||||
a: addr || '',
|
||||
eventId: ev.id,
|
||||
creatorPubkey: ev.pubkey,
|
||||
title: decryptedPayload?.meta?.title || '',
|
||||
mode: decryptedPayload?.templateMode || '',
|
||||
unlockedAt: Date.now(),
|
||||
amountSats: totalSats,
|
||||
previewJpegDataUrl: envelope?.preview?.dataUrl || '',
|
||||
payload: decryptedPayload,
|
||||
payment: {
|
||||
mode: 'recovered-from-receipt',
|
||||
receipts: matchedReceipts.map((r) => ({ id: r.id, kind: r.kind })),
|
||||
},
|
||||
};
|
||||
upsertPurchaseVaultItem(vaultItem).catch((err) => {
|
||||
console.warn('[vault] back-fill from receipt failed for', ev.id, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -5898,7 +5971,6 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (statusToast?.dismiss) statusToast.dismiss();
|
||||
markUnlocked(eventId, {
|
||||
creatorSats: result.creatorSats,
|
||||
platformSats: result.platformSats,
|
||||
@@ -5908,9 +5980,17 @@
|
||||
payload: decryptedPayload || undefined,
|
||||
previewJpegDataUrl: row.premium?.previewJpegDataUrl || undefined,
|
||||
});
|
||||
// Persist to the Nostr-synced vault in the background so the unlock
|
||||
// survives localStorage.clear() and shows up on other devices.
|
||||
// Persist to the Nostr-synced vault BEFORE telling the user
|
||||
// the unlock is complete. The local cache flip above keeps the
|
||||
// UI snappy, but a fire-and-forget publish was masking real
|
||||
// durability failures: a vault that never persisted left the
|
||||
// user with a localhost-only unlock that wouldn't show up on
|
||||
// any other device or domain.
|
||||
let vaultSynced = !row.premium || !decryptedPayload;
|
||||
let vaultErr = null;
|
||||
if (row.premium && decryptedPayload) {
|
||||
if (statusToast?.dismiss) statusToast.dismiss();
|
||||
statusToast = toast('Unlocked. Syncing to your purchase vault…', { duration: 30000 });
|
||||
const vaultItem = _buildVaultItemForUnlock({
|
||||
row,
|
||||
totalSats: min,
|
||||
@@ -5920,9 +6000,23 @@
|
||||
platformPreimage: result.platformPreimage,
|
||||
payload: decryptedPayload,
|
||||
});
|
||||
upsertPurchaseVaultItem(vaultItem).catch((err) => console.warn('[vault] upsert failed', err));
|
||||
try {
|
||||
await upsertPurchaseVaultItem(vaultItem);
|
||||
vaultSynced = true;
|
||||
} catch (err) {
|
||||
vaultErr = err;
|
||||
console.warn('[vault] upsert failed', err);
|
||||
}
|
||||
}
|
||||
if (statusToast?.dismiss) statusToast.dismiss();
|
||||
if (vaultSynced) {
|
||||
toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
|
||||
} else {
|
||||
toastError(
|
||||
`Unlocked "${row.title || 'template'}" on this device, but vault sync failed (${vaultErr?.message || 'no relay accepted the event'}). Your purchase is saved locally; other devices won’t see it until sync recovers.`,
|
||||
{ duration: 12000 }
|
||||
);
|
||||
}
|
||||
toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
|
||||
// Re-render the feed in place — the card will now show Import.
|
||||
if (typeof _currentFeedRerender === 'function') {
|
||||
try { _currentFeedRerender(); } catch {}
|
||||
@@ -5977,6 +6071,26 @@
|
||||
|
||||
function _supportsVault() { return !!nostrEncryptCapability(); }
|
||||
|
||||
// Vault and zap-receipt traffic uses a wider relay set than ordinary
|
||||
// feed queries: the user's configured relays unioned with the
|
||||
// app-default list (DEFAULT_NOSTR_RELAYS). This keeps purchases
|
||||
// visible across browsers / devices / domains even when the user
|
||||
// edits or narrows their relay list. NIP-65 write relays could be
|
||||
// folded in here later without changing any call sites.
|
||||
function _vaultRelayUnion() {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
const add = (r) => {
|
||||
const norm = normalizeRelayHealthRelay(r);
|
||||
if (!norm || seen.has(norm)) return;
|
||||
seen.add(norm);
|
||||
out.push(norm);
|
||||
};
|
||||
for (const r of splitRelayList(state.community?.relays || '')) add(r);
|
||||
for (const r of DEFAULT_NOSTR_RELAYS) add(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _selfEncrypt(plaintext) {
|
||||
const method = nostrEncryptCapability();
|
||||
if (!method) throw new Error('Your Nostr extension does not support encryption (NIP-44 or NIP-04).');
|
||||
@@ -6035,7 +6149,7 @@
|
||||
}
|
||||
_vaultLoadPromise = (async () => {
|
||||
try {
|
||||
const relays = splitRelayList(state.community?.relays || '');
|
||||
const relays = _vaultRelayUnion();
|
||||
const events = await queryRelays(relays, {
|
||||
kinds: [30078], authors: [myPubkey], '#d': [VAULT_D_TAG], limit: 5,
|
||||
}, { timeoutMs: 4500 });
|
||||
@@ -6132,7 +6246,11 @@
|
||||
],
|
||||
content,
|
||||
};
|
||||
return { signed: await publishCommunityEvent(evt), encryptedSize: content.length };
|
||||
const signed = await signNostrEvent(evt);
|
||||
// Publish to the union (user relays + defaults) so the vault is
|
||||
// reachable from any device / domain that shares the login.
|
||||
await publishToRelays(signed, _vaultRelayUnion());
|
||||
return { signed, encryptedSize: content.length };
|
||||
}
|
||||
|
||||
// Publish the full vault. Tries single-event first; if encrypted size
|
||||
@@ -6182,8 +6300,10 @@
|
||||
const current = await loadPurchaseVault({ force: true });
|
||||
const mergedItems = _vaultMerge(current.items || [], [item]);
|
||||
const next = { v: 1, updatedAt: Date.now(), items: mergedItems };
|
||||
try { return await _publishVault(next); }
|
||||
catch (err) { console.warn('[vault] publish failed', err); return null; }
|
||||
// Propagate publish failures to the caller so the UI can show a
|
||||
// real error instead of silently leaving the user's local state
|
||||
// drifted from the relay-synced source of truth.
|
||||
return await _publishVault(next);
|
||||
}
|
||||
|
||||
function _buildVaultItemForUnlock({ row, totalSats, creatorSats, platformSats, creatorPreimage, platformPreimage, payload }) {
|
||||
|
||||
Reference in new issue
Block a user