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();