Add private (encrypted) publishing — Phases 1 & 2

Phase 1 — publish a design encrypted, readable only by the author:
- Crypto helpers: encryptProjectEnvelope / decryptProjectEnvelope —
  AES-256-GCM over gzipped JSON, AES key NIP-44 key-wrapped to the
  author (NIP-04 fallback), via a nostrEncryptCapability() probe. All
  in-browser, no new dependencies.
- publishEncryptedDesign: a feed-invisible kind:30078 event — random
  persistent d-tag, only d / alt / encrypted tags, no casewrap tag.
- Publish-confirm modal gains a Public / Private control with the
  privacy warning and a capability gate; wired into the template and
  disc-design publish paths.
- My Designs decrypts the author's own private events and shows a
  lock badge; buildRowsFromEvents skips any stray encrypted event so it
  can never surface in the public feed.
- state.visibility / state.privateDTag — restored on load, cleared on
  Close project, autosaved.

Phase 2 — flip a published design's visibility in place:
- Owner-only Make Public / Make Private button on the preview page;
  republishDesignVisibility re-publishes under the same d-tag with the
  opposite wrapper (NIP-33 replace), behind a confirm that states the
  public-to-private caveat.

Spec status documented in docs/PRIVATE_PUBLISHING_FEATURE.md.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-22 13:48:59 -04:00
1 parent a0ebd9c9e2
commit 3e6e89e16f
2 files changed
+317 -1

No files matched your search

+33
View File
@@ -38,6 +38,39 @@ plaintext JSON.
4. **No `SCHEMA_VERSION` bump** — the decrypted payload is unchanged
(still v5). The *encryption envelope* is versioned separately.
### Implementation status
- **Phase 0 — codebase audit: done.** The app uses only `getPublicKey` /
`signEvent`; **no NIP-04/44 anywhere** — the `kind:10000` mute list is
plain `p` tags, so there is no crypto codepath to reuse. `CompressionStream`
is assumed (with a `compression:"none"` fallback). Extension-runtime
checks (encrypt-to-self on Alby/nos2x, relay size) are validated by the
user in-browser; the build is defensive (capability probe + NIP-04
fallback) so it works regardless.
- **Phase 1 — built (pending in-browser testing).** Done:
- Crypto helpers — `encryptProjectEnvelope` / `decryptProjectEnvelope`
(AES-256-GCM over gzip, NIP-44 key-wrap with NIP-04 fallback,
`nostrEncryptCapability` probe).
- `publishEncryptedDesign` — feed-invisible event: random persistent
`d`-tag, only `d` / `alt` / `encrypted` tags, no `casewrap` tag.
- Publish-confirm modal Public/Private control + private warning +
capability gate; private branch wired into `publishCurrentTemplate`
and `publishDiscDesign`.
- My Designs decrypts the author's own private events and shows a
🔒 Private badge; `buildRowsFromEvents` skips stray encrypted events.
- `state.visibility` / `state.privateDTag`; restored on load, cleared
on close.
- Known v1 gaps: file-save writes plaintext without a `visibility`
flag (re-pick Private on publish); a failed decrypt in My Designs is
skipped silently rather than shown as a locked card.
- **Phase 2 — built (pending in-browser testing).** An owner-only
**Make Public / Make Private** button on a design's preview page
(`republishDesignVisibility`) re-publishes the design under the same
`d`-tag with the opposite wrapper — a NIP-33 in-place replace — behind
a confirm that states the public→private caveat. The button is gated
on encryption capability for the →private direction.
- **Phases 3–4 — not started.**
### The honest caveat
Encryption protects the design *contents*. It does **not** hide that an
+284 -1
View File
@@ -1828,6 +1828,7 @@
.feedCard.feedCardBroken{opacity:.55}
.feedCard.feedCardBroken .feedCardThumb{background:repeating-linear-gradient(45deg,#f1f5f9,#f1f5f9 10px,#e2e8f0 10px,#e2e8f0 20px)}
.feedCardBrokenBadge{position:absolute;top:6px;right:6px;background:rgba(220,38,38,.92);color:#fff;font-size:.62rem;font-weight:700;border-radius:4px;padding:2px 6px;z-index:6;letter-spacing:.03em}
.feedCardPrivateBadge{position:absolute;top:6px;left:6px;background:rgba(15,23,42,.92);color:#fff;font-size:.62rem;font-weight:700;border-radius:4px;padding:2px 6px;z-index:6;letter-spacing:.03em}
.feedCardSoftModOverlay{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(15,23,42,.55);color:#fff;padding:12px;text-align:center;font-size:.78rem;line-height:1.3}
.feedCardSoftModOverlay .softModLabel{font-weight:600;max-width:90%}
.feedCardSoftModOverlay button{font-size:.74rem;padding:5px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.5);background:rgba(255,255,255,.12);color:#fff;cursor:pointer}
@@ -3593,6 +3594,18 @@
<p class="hint" id="publishConfirmSubtitle" style="margin:0 0 12px">Double-check the details below before posting to Nostr. You can edit or delete later, but the original event will exist on relays.</p>
<div id="publishConfirmPreview" class="publishConfirmPreview"></div>
<dl id="publishConfirmMetaList" class="publishConfirmMeta"></dl>
<div id="publishConfirmVisibility" style="margin:0 0 12px">
<div style="font-weight:600;font-size:.82rem;margin:0 0 6px">Visibility</div>
<label class="toggle" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 4px;cursor:pointer">
<input type="radio" name="pubVisibility" value="public" />
<span>Public — listed in the community feed; anyone can open it.</span>
</label>
<label class="toggle" id="publishConfirmPrivateLabel" style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
<input type="radio" name="pubVisibility" value="private" />
<span>Private — encrypted; only you can open it; not in the feed.</span>
</label>
<p id="publishConfirmPrivateNote" class="hint" style="display:none;margin:8px 0 0;padding:8px 10px;border:1px solid rgba(251,191,36,.4);background:rgba(251,191,36,.1);color:#92400e;border-radius:6px;font-size:.78rem">Private designs are encrypted — relays and other people can't read the contents. The event still reveals that you published something, and when. Encryption can't undo a past public publish.</p>
</div>
<p id="publishConfirmEditNote" class="hint" style="display:none;margin:0 0 12px;padding:8px 10px;border:1px solid rgba(251,191,36,.4);background:rgba(251,191,36,.1);color:#92400e;border-radius:6px;font-size:.78rem"></p>
<p id="publishConfirmError" class="hint" style="display:none;margin:0 0 12px;color:var(--warn);font-size:.82rem"></p>
<div class="buttonRow" style="margin:0;justify-content:flex-end;flex-wrap:wrap;gap:8px">
@@ -4001,6 +4014,11 @@
editingKind: 0,
editingEventId: '',
editingTitle: '',
// Private publishing: 'public' (feed-listed) or 'private' (encrypted,
// only the author can open it). privateDTag is the persistent random
// d-tag a private design keeps so edits replace it in place.
visibility: 'public',
privateDTag: '',
preset: 'dvd',
panelW: presets.dvd.panelW,
trimH: presets.dvd.trimH,
@@ -9191,6 +9209,173 @@
return signed;
}
// ===================================================================
// Private publishing — encrypt a design so only the author can read
// it. AES-256-GCM over gzip(JSON), with the AES key NIP-44/NIP-04
// key-wrapped to the author. See docs/PRIVATE_PUBLISHING_FEATURE.md.
// ===================================================================
function nostrEncryptCapability() {
const n = window.nostr;
if (n && n.nip44 && typeof n.nip44.encrypt === 'function') return 'nip44';
if (n && n.nip04 && typeof n.nip04.encrypt === 'function') return 'nip04';
return null;
}
function _bytesToB64(bytes) {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function _b64ToBytes(str) {
const s = atob(str);
const b = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
}
async function _gzipBytes(bytes) {
if (typeof CompressionStream === 'undefined') return { data: bytes, compression: 'none' };
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip'));
return { data: new Uint8Array(await new Response(stream).arrayBuffer()), compression: 'gzip' };
}
async function _gunzipBytes(bytes, compression) {
if (compression !== 'gzip' || typeof DecompressionStream === 'undefined') return bytes;
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function _wrapAesKey(rawKey, selfPubkey, method) {
const keyB64 = _bytesToB64(rawKey);
return method === 'nip44'
? await window.nostr.nip44.encrypt(selfPubkey, keyB64)
: await window.nostr.nip04.encrypt(selfPubkey, keyB64);
}
async function _unwrapAesKey(wrapped, selfPubkey, method) {
const keyB64 = method === 'nip44'
? await window.nostr.nip44.decrypt(selfPubkey, wrapped)
: await window.nostr.nip04.decrypt(selfPubkey, wrapped);
return _b64ToBytes(keyB64);
}
// Encrypt a design payload into the publishable envelope.
async function encryptProjectEnvelope(payload) {
const method = nostrEncryptCapability();
if (!method) throw new Error('Your Nostr extension does not support encryption (NIP-44 / NIP-04).');
const selfPubkey = normalizePubkey(state.community?.myPubkey || '');
if (!selfPubkey) throw new Error('Sign in with Nostr before publishing privately.');
const rawBytes = new TextEncoder().encode(JSON.stringify(payload));
const { data: compressed, compression } = await _gzipBytes(rawBytes);
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, compressed));
const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key));
const wrappedKey = await _wrapAesKey(rawKey, selfPubkey, method);
return {
v: 1,
visibility: 'encrypted',
enc: { alg: 'aes-256-gcm', keywrap: method, compression },
iv: _bytesToB64(iv),
ciphertext: _bytesToB64(ct),
recipients: [{ pubkey: selfPubkey, wrappedKey }]
};
}
// Decrypt an envelope back to the design payload.
async function decryptProjectEnvelope(envelope) {
const method = envelope?.enc?.keywrap || nostrEncryptCapability();
if (!method) throw new Error('Your Nostr extension does not support decryption.');
const selfPubkey = normalizePubkey(state.community?.myPubkey || '');
const mine = (envelope.recipients || []).find(r => normalizePubkey(r.pubkey) === selfPubkey);
if (!mine) throw new Error('This private design is not addressed to you.');
const rawKey = await _unwrapAesKey(mine.wrappedKey, selfPubkey, method);
const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']);
const iv = _b64ToBytes(envelope.iv);
const ct = _b64ToBytes(envelope.ciphertext);
const compressed = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct));
const rawBytes = await _gunzipBytes(compressed, envelope?.enc?.compression);
return JSON.parse(new TextDecoder().decode(rawBytes));
}
function isEncryptedEnvelope(obj) {
return !!obj && obj.visibility === 'encrypted' && typeof obj.ciphertext === 'string';
}
// The persistent random d-tag a private design keeps across edits.
function ensurePrivateDTag() {
if (!state.privateDTag) {
state.privateDTag = `artstr-private-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
return state.privateDTag;
}
// Publish a design payload as an encrypted, feed-invisible event.
async function publishEncryptedDesign(payload) {
const envelope = await encryptProjectEnvelope(payload);
ensurePrivateDTag();
const dTag = isEditing() ? state.editingDTag : state.privateDTag;
const tags = [
['d', dTag],
['alt', 'Encrypted Artstr design'],
['encrypted', envelope.enc.keywrap]
];
const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(envelope) };
return await publishCommunityEvent(evt);
}
function typeTagForMode(mode) {
if (mode === 'disc') return 'casewrap-disc';
if (mode === 'jewel') return 'casewrap-jewel';
if (mode === 'deck') return 'casewrap-deck';
if (mode === 'slide') return 'casewrap-slide';
if (mode === 'customart') return 'casewrap-customart';
if (mode === 'disc-design') return 'casewrap-disc-design';
return 'casewrap-cover';
}
// Flip a published design between public and private in place — a fresh
// event under the same d-tag with the opposite wrapper (NIP-33 replace).
async function republishDesignVisibility(row, makePrivate) {
if (!state.community?.myPubkey) { toastError('Sign in with Nostr first.'); return; }
const dTag = rowDTag(row);
if (!dTag) { toastError('This design has no address to update.'); return; }
if (makePrivate) {
if (!confirm('Make this design private?\n\nIt will be encrypted and removed from the community feed. Anyone who already saw or cached the public version may still have a copy — encryption cannot erase the past.')) return;
} else {
if (!confirm('Make this design public?\n\nIt will be decrypted and listed in the community feed for anyone to see.')) return;
}
try {
const payload = JSON.parse(JSON.stringify(row.payload || {}));
const created_at = Math.floor(Date.now() / 1000);
let evt;
if (makePrivate) {
payload.visibility = 'private';
payload.privateDTag = dTag;
const envelope = await encryptProjectEnvelope(payload);
evt = {
kind: 30078, created_at,
tags: [['d', dTag], ['alt', 'Encrypted Artstr design'], ['encrypted', envelope.enc.keywrap]],
content: JSON.stringify(envelope)
};
} else {
delete payload.visibility;
delete payload.privateDTag;
const mode = normalizeTemplateMode(payload);
evt = {
kind: 30078, created_at,
tags: [['d', dTag], ...getTemplateTags(payload, typeTagForMode(mode))],
content: JSON.stringify(payload)
};
}
const signed = await publishCommunityEvent(evt);
cachePublishedTemplateEvent(signed);
toastSuccess(makePrivate ? 'Design is now private.' : 'Design is now public.');
closeNostrPreview();
await loadMyDesigns();
} catch (err) {
toastError(err?.message || 'Could not change the design’s visibility.');
}
}
function buildTemplateTombstonePayload(payload, { reason = '', deletedAt = Math.floor(Date.now() / 1000), deletedBy = '' } = {}) {
const next = JSON.parse(JSON.stringify(payload || {}));
const meta = { ...(next.meta || {}) };
@@ -9414,6 +9599,17 @@
throw new Error('Add cover artwork or at least one layer before publishing.');
}
}
// Private: encrypt and publish a feed-invisible event instead of the
// normal public, tagged one.
if (state.visibility === 'private') {
payload.visibility = 'private';
payload.privateDTag = ensurePrivateDTag();
const signed = await publishEncryptedDesign(payload);
const label = isEditing() ? `Updated "${state.meta.title}" (private)` : `Private "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
if (isEditing()) exitEditMode();
return signed;
}
const mode = normalizeTemplateMode(payload);
const dBase = state.meta.imdbId
|| state.meta.title
@@ -9446,6 +9642,15 @@
if (!designName?.trim()) {
throw new Error('Add a title in Project Metadata or a custom design name before publishing.');
}
if (state.visibility === 'private') {
payload.visibility = 'private';
payload.privateDTag = ensurePrivateDTag();
const signed = await publishEncryptedDesign(payload);
const label = isEditing() ? `Updated "${designName}" (private)` : `Private "${designName}"`;
await finalizePublishedTemplateEvent(signed, { label });
if (isEditing()) exitEditMode();
return signed;
}
const dTag = isEditing() ? state.editingDTag : `disc-design:${state.meta.imdbId || designName}`;
const tags = [
['d', dTag],
@@ -11346,6 +11551,8 @@
state.jewelSpineLabels = { left: '', right: '' };
state.customArt = { width: 1920, height: 1080, background: '#ffffff', preset: '1920x1080' };
state.deck = { theme: { fontFamily: '', background: '' }, slides: [], editingIndex: null };
state.visibility = 'public';
state.privateDTag = '';
state.meta = blankMeta();
state.layers = (state.layers || []).filter(l => l.target === 'designer-disc');
state.selectedLayerId = '';
@@ -11523,11 +11730,33 @@
metaEl.innerHTML = rows.map(([k, v]) => `<div class="publishConfirmMetaRow"><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd></div>`).join('');
}
// Visibility control — Public / Private (encrypted).
const cap = nostrEncryptCapability();
const privLabel = $('publishConfirmPrivateLabel');
const privInput = privLabel ? privLabel.querySelector('input') : null;
const pubInput = document.querySelector('input[name="pubVisibility"][value="public"]');
let vis = state.visibility === 'private' ? 'private' : 'public';
if (!cap) vis = 'public';
state.visibility = vis;
if (privInput) privInput.disabled = !cap;
if (privLabel) {
privLabel.style.opacity = cap ? '1' : '.5';
privLabel.title = cap ? '' : 'Your Nostr extension does not support encryption (NIP-44 / NIP-04).';
}
if (pubInput) pubInput.checked = vis === 'public';
if (privInput) privInput.checked = vis === 'private';
updatePublishConfirmVisibilityNote();
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
bringModalToFront(modal);
}
function updatePublishConfirmVisibilityNote() {
const note = $('publishConfirmPrivateNote');
if (note) note.style.display = state.visibility === 'private' ? '' : 'none';
}
function closePublishConfirmModal() {
const modal = $('publishConfirmModal');
if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); }
@@ -11641,6 +11870,10 @@
// Loading a project file is always a fresh-start operation — never an
// in-place edit of a published event. Drop any prior editing context.
exitEditMode();
// Restore publish visibility — a decrypted private design carries
// `visibility`/`privateDTag` so re-publishing replaces it in place.
state.visibility = parsed.visibility === 'private' ? 'private' : 'public';
state.privateDTag = parsed.privateDTag || '';
if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) {
importDiscDesignFromPayload(parsed, 'a', { openDesigner: true });
return;
@@ -13662,6 +13895,9 @@
if (authorPubkey && _blockedAuthors.has(authorPubkey)) continue;
let payload = null;
try { payload = JSON.parse(e.content || '{}'); } catch { continue; }
// Encrypted designs never render here — they're feed-invisible and
// are pre-decrypted by loadMyDesigns before reaching this builder.
if (isEncryptedEnvelope(payload)) continue;
if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue;
const address = templateEventAddress(e, payload);
const eventTime = e.created_at || 0;
@@ -13846,6 +14082,12 @@
badge.textContent = 'MISSING IMAGE';
thumb.appendChild(badge);
}
if (row.private) {
const lockBadge = document.createElement('span');
lockBadge.className = 'feedCardPrivateBadge';
lockBadge.textContent = '🔒 Private';
thumb.appendChild(lockBadge);
}
if (showBlur) {
const overlay = document.createElement('div');
overlay.className = 'feedCardSoftModOverlay';
@@ -14039,7 +14281,23 @@
}
}
const ownEvents = mergeCachedPublishedEvents(events).filter(event => event.pubkey === state.community.myPubkey);
const rows = buildRowsFromEvents(ownEvents, typeFilter, '', false, categoryFilter, languageFilter);
// Decrypt the user's own private events so they render in My Designs.
// (A failed decrypt — extension locked, etc. — is skipped silently.)
const prepared = [];
for (const e of ownEvents) {
let parsed = null;
if (e.kind === 30078) { try { parsed = JSON.parse(e.content || '{}'); } catch {} }
if (isEncryptedEnvelope(parsed)) {
try {
const decoded = await decryptProjectEnvelope(parsed);
prepared.push({ ...e, content: JSON.stringify(decoded), _private: true });
} catch (err) { /* can't decrypt — omit */ }
} else {
prepared.push(e);
}
}
const rows = buildRowsFromEvents(prepared, typeFilter, '', false, categoryFilter, languageFilter);
rows.forEach((r) => { if (r.e && r.e._private) r.private = true; });
rows.sort((a, b) => b.createdAt - a.createdAt);
wall.innerHTML = '';
if (!rows.length) {
@@ -14550,6 +14808,23 @@
editBtn.title = 'Edit this design and publish an update (replaces the original)';
editBtn.addEventListener('click', () => startEditFromRow(row));
actionsDiv.insertBefore(editBtn, saveJsonBtn);
// Owner-only Visibility toggle — flip a published design between
// public (feed-listed) and private (encrypted) in place.
const makePrivate = !row.private;
const visBtn = document.createElement('button');
visBtn.type = 'button';
visBtn.textContent = makePrivate ? 'Make Private' : 'Make Public';
if (makePrivate && !nostrEncryptCapability()) {
visBtn.disabled = true;
visBtn.title = 'Your Nostr extension does not support encryption (NIP-44 / NIP-04).';
} else {
visBtn.title = makePrivate
? 'Re-publish this design encrypted, so only you can open it'
: 'Re-publish this private design publicly, listed in the community feed';
}
visBtn.addEventListener('click', () => republishDesignVisibility(row, makePrivate));
actionsDiv.insertBefore(visBtn, saveJsonBtn);
}
// Lightning tip button — only shown when the author has a resolvable LN address.
@@ -17499,6 +17774,14 @@
on('publishConfirmCloseBtn', 'click', closePublishConfirmModal);
on('publishConfirmCancelBtn', 'click', closePublishConfirmModal);
on('publishConfirmPublishBtn', 'click', handlePublishConfirmPublish);
document.querySelectorAll('input[name="pubVisibility"]').forEach((radio) => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
state.visibility = e.target.value === 'private' ? 'private' : 'public';
updatePublishConfirmVisibilityNote();
}
});
});
on('supportDevCloseBtn', 'click', closeSupportDevModal);
on('supportDevNoBtn', 'click', closeSupportDevModal);
on('supportDevLaterBtn', 'click', closeSupportDevModal);