diff --git a/src/index.html b/src/index.html index 86dd97c..9f9e3f1 100644 --- a/src/index.html +++ b/src/index.html @@ -5631,6 +5631,21 @@

Blocked authors are hidden in all community feed views.

No blocked authors yet.
+
⚡ Lightning wallet ⓘ
@@ -26183,6 +26198,86 @@ throw new Error('No valid Artstr premium policy was found on your relays. Ask the Artstr admin to publish the Premium 2.0 policy event, then try publishing again.'); } + function isPremiumPolicyAdmin() { + return !!state.community?.myPubkey && normalizePubkey(state.community.myPubkey) === normalizePubkey(platform.getPubkeyHex()); + } + + function updatePremiumPolicyAdminVisibility() { + const details = $('premiumPolicyAdminDetails'); + if (!details) return; + const visible = isPremiumPolicyAdmin(); + details.style.display = visible ? '' : 'none'; + if (visible && !details.dataset.initialized) { + details.dataset.initialized = 'true'; + refreshPremiumPolicyAdminStatus().catch(() => {}); + } + } + + function buildPremiumPolicyFromAdminForm() { + const softgateEpoch = ($('premiumPolicySoftgateEpoch')?.value || '2026-07').trim(); + const claimEpoch = ($('premiumPolicyClaimEpoch')?.value || softgateEpoch).trim(); + const defaultClaimDays = Math.max(7, Math.min(180, Number($('premiumPolicyDefaultDays')?.value) || 90)); + const status = ($('premiumPolicyEpochStatus')?.value || 'active').trim(); + return { + v: 1, + activeSoftgateEpoch: softgateEpoch, + activeClaimEpoch: claimEpoch, + defaultClaimDays, + minClaimDays: 7, + maxClaimDays: 180, + privateCopies: 'required', + firstUnlockDefault: 'allowed', + issuedAt: Math.floor(Date.now() / 1000), + epochs: { + '2026-05': { + status: softgateEpoch === '2026-05' ? status : 'legacy', + firstUnlock: 'allowed', + privateCopies: 'allowed', + softgateKdf: 'artstr-softgate-v1', + minClientPolicy: 1, + }, + [softgateEpoch]: { + status, + firstUnlock: status === 'closed' ? 'closed' : 'allowed', + privateCopies: 'required', + softgateKdf: softgateEpoch === '2026-05' ? 'artstr-softgate-v1' : 'artstr-softgate-v1.5', + workFactor: 1, + }, + }, + }; + } + + async function refreshPremiumPolicyAdminStatus() { + const statusEl = $('premiumPolicyStatus'); + if (!statusEl || !isPremiumPolicyAdmin()) return; + statusEl.textContent = 'Checking relays…'; + try { + const loaded = await _loadPremiumPublishPolicy(); + const policy = loaded.policy || {}; + if ($('premiumPolicySoftgateEpoch')) $('premiumPolicySoftgateEpoch').value = policy.activeSoftgateEpoch || '2026-07'; + if ($('premiumPolicyClaimEpoch')) $('premiumPolicyClaimEpoch').value = policy.activeClaimEpoch || policy.activeSoftgateEpoch || '2026-07'; + if ($('premiumPolicyDefaultDays')) $('premiumPolicyDefaultDays').value = String(policy.defaultClaimDays || 90); + if ($('premiumPolicyEpochStatus')) $('premiumPolicyEpochStatus').value = policy.epochs?.[policy.activeSoftgateEpoch]?.status || 'active'; + statusEl.textContent = `Active policy: ${policy.activeSoftgateEpoch || 'unknown'} / claim ${policy.activeClaimEpoch || 'unknown'}.`; + } catch (err) { + statusEl.textContent = err?.message || 'No valid premium policy found.'; + } + } + + async function publishPremiumPolicyFromSettings() { + if (!isPremiumPolicyAdmin()) throw new Error('Only the Artstr platform admin key can publish premium policy.'); + const helpers = window.ArtstrPremium2; + if (!helpers?.buildPremiumPolicyEventDraft || !helpers?.validatePremiumPolicyEvent) { + throw new Error('Premium 2.0 policy helper module did not load.'); + } + const policy = buildPremiumPolicyFromAdminForm(); + const draft = helpers.buildPremiumPolicyEventDraft(policy); + const signed = await publishCommunityEvent(draft); + const validated = helpers.validatePremiumPolicyEvent(signed, { platformPubkey: platform.getPubkeyHex() }); + if (!validated.ok) throw new Error(`Published policy did not validate: ${validated.reason}`); + return signed; + } + async function _publishPremiumDesign(payload, mode, typeTag, attributionTags = []) { // Pre-flight: lud16 on creator (already enforced at the modal level), // and resolve the platform lud16 so the split-zap actually works for @@ -35090,6 +35185,7 @@ profileBtn.style.display = 'none'; } updateTrustToggles(); + if (typeof updatePremiumPolicyAdminVisibility === 'function') updatePremiumPolicyAdminVisibility(); requestAnimationFrame(updateNostrWidgetSpace); } @@ -39108,6 +39204,26 @@ on('profileMenuSettings', 'click', openSettingsModal); on('profileMenuLogout', 'click', nostrLogout); on('settingsCloseBtn', 'click', closeSettingsModal); + on('premiumPolicyRefreshBtn', 'click', () => refreshPremiumPolicyAdminStatus().catch((err) => { + const el = $('premiumPolicyStatus'); + if (el) el.textContent = err?.message || 'Policy refresh failed.'; + })); + on('premiumPolicyPublishBtn', 'click', async () => { + const btn = $('premiumPolicyPublishBtn'); + const statusEl = $('premiumPolicyStatus'); + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = 'Publishing premium policy…'; + try { + const signed = await publishPremiumPolicyFromSettings(); + if (statusEl) statusEl.textContent = `Published policy ${shortPubkey(signed.id)}.`; + toastSuccess('Premium policy published ✓'); + } catch (err) { + if (statusEl) statusEl.textContent = err?.message || 'Policy publish failed.'; + toastError(err?.message || 'Policy publish failed.'); + } finally { + if (btn) btn.disabled = false; + } + }); const settingsModalEl = $('settingsModal'); if (settingsModalEl) settingsModalEl.addEventListener('click', (e) => { if (e.target === settingsModalEl) closeSettingsModal(); }); // Escape key closes both the menu and the settings modal. diff --git a/src/premium2-helpers.mjs b/src/premium2-helpers.mjs index e57468f..06291d8 100644 --- a/src/premium2-helpers.mjs +++ b/src/premium2-helpers.mjs @@ -86,6 +86,30 @@ export function buildPremiumPublishStamp(policy, { }; } +export function buildPremiumPolicyEventDraft(policy, { + createdAt = Math.floor(Date.now() / 1000), +} = {}) { + if (!policy || typeof policy !== 'object' || policy.v !== 1) { + throw new TypeError('Premium policy v1 is required.'); + } + if (!policy.activeSoftgateEpoch || !policy.activeClaimEpoch || !policy.epochs?.[policy.activeSoftgateEpoch]) { + throw new Error('Premium policy is missing active epoch fields.'); + } + return { + kind: 30078, + created_at: createdAt, + tags: [ + ['d', PREMIUM_POLICY_D_TAG], + ['client', 'Artstr Studio'], + ['policy', 'premium-policy-v1'], + ], + content: JSON.stringify({ + ...policy, + issuedAt: Number(policy.issuedAt) || createdAt, + }), + }; +} + function bytesToBase64Url(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); diff --git a/tests/premium/premium2-helpers.test.mjs b/tests/premium/premium2-helpers.test.mjs index ebb90f7..d20a795 100644 --- a/tests/premium/premium2-helpers.test.mjs +++ b/tests/premium/premium2-helpers.test.mjs @@ -9,6 +9,7 @@ import { buildPurchaseCopyCoordinate, buildPurchaseCopyPlaintext, buildPremiumPublishStamp, + buildPremiumPolicyEventDraft, buildVaultV2Item, claimStatus, normalizeReceiptEvidence, @@ -92,6 +93,27 @@ test('rejects premium publish stamps for unsupported or closed policy epochs', a ); }); +test('builds admin premium policy event drafts with signed-data-only content', async () => { + const valid = await fixture('policy-valid-active.json'); + const { policy } = validatePremiumPolicyEvent(valid, { platformPubkey }); + const draft = buildPremiumPolicyEventDraft(policy, { createdAt: 1783200000 }); + + assert.equal(draft.kind, 30078); + assert.equal(draft.created_at, 1783200000); + assert.deepEqual(draft.tags, [ + ['d', 'artstr:premium-policy:v1'], + ['client', 'Artstr Studio'], + ['policy', 'premium-policy-v1'], + ]); + + const content = JSON.parse(draft.content); + assert.equal(content.v, 1); + assert.equal(content.activeSoftgateEpoch, '2026-07'); + assert.equal(content.activeClaimEpoch, '2026-07'); + assert.equal(content.issuedAt, 1783200000); + assert.doesNotMatch(draft.content, /function|eval|script|wasm/i); +}); + test('resolves vault v2 private copies before legacy inline payloads', async () => { const v2 = await fixture('vault-v2-private-copy-pointer.json'); assert.deepEqual(resolveVaultItem(v2), { diff --git a/tests/premium/premium2-index-integration.test.mjs b/tests/premium/premium2-index-integration.test.mjs index 58edf70..10f0811 100644 --- a/tests/premium/premium2-index-integration.test.mjs +++ b/tests/premium/premium2-index-integration.test.mjs @@ -41,6 +41,21 @@ test('index page wires admin policy into premium publish stamping', async () => assert.match(html, /\['encrypted', premiumPolicy\.stamp\.encryptedTag\]/); }); +test('index page exposes admin-only premium policy publishing controls', async () => { + const html = await indexHtml(); + + assert.match(html, /id="premiumPolicyAdminDetails"/); + assert.match(html, /id="premiumPolicyPublishBtn"/); + assert.match(html, /function isPremiumPolicyAdmin\(\)/); + assert.match(html, /normalizePubkey\(state\.community\.myPubkey\) === normalizePubkey\(platform\.getPubkeyHex\(\)\)/); + assert.match(html, /function buildPremiumPolicyFromAdminForm\(\)/); + assert.match(html, /async function publishPremiumPolicyFromSettings\(\)/); + assert.match(html, /helpers\.buildPremiumPolicyEventDraft\(policy\)/); + assert.match(html, /helpers\.validatePremiumPolicyEvent\(signed, \{ platformPubkey: platform\.getPubkeyHex\(\) \}\)/); + assert.match(html, /on\('premiumPolicyPublishBtn', 'click'/); + assert.match(html, /Premium policy published/); +}); + test('soft-gate crypto supports the active Premium 2.0 policy epoch', async () => { const html = await indexHtml();