diff --git a/src/index.html b/src/index.html index 7f58805..4c84095 100644 --- a/src/index.html +++ b/src/index.html @@ -6488,9 +6488,15 @@ new Uint8Array([0xa5, 0x71, 0x4e, 0x2c, 0x9b, 0x88, 0x33, 0xd0]), // palette seed III new Uint8Array([0x62, 0x09, 0xfd, 0x18, 0x4a, 0xe7, 0xc4, 0x95]), // palette seed IV ], + '2026-07': [ + new Uint8Array([0x3f, 0x92, 0x0d, 0xbb, 0x51, 0x7a, 0xe4, 0x26]), // palette seed I + new Uint8Array([0xd6, 0x44, 0x83, 0x19, 0xac, 0x05, 0x7e, 0xf1]), // palette seed II + new Uint8Array([0x28, 0xcd, 0x70, 0x5a, 0x9f, 0x31, 0xb8, 0x64]), // palette seed III + new Uint8Array([0xea, 0x16, 0x4b, 0xc7, 0x02, 0xdd, 0x90, 0x38]), // palette seed IV + ], }; const SUPPORTED_EPOCHS = Object.freeze(Object.keys(_softGatePepperParts)); - const CURRENT_EPOCH = '2026-05'; + const CURRENT_EPOCH = '2026-07'; const PREMIUM_KDF_INFO = 'artstr-softgate-v1'; function pepperBytesForEpoch(epoch) { @@ -25776,12 +25782,41 @@ // Encrypts the payload via Premium.encryptPayload, renders the // watermarked preview JPEG, builds the ยง5.1 tag set including the // platform-fee zap split, and publishes a single kind-30078 event. + async function _loadPremiumPublishPolicy() { + const helpers = window.ArtstrPremium2; + if (!helpers?.validatePremiumPolicyEvent || !helpers?.buildPremiumPublishStamp) { + throw new Error('Premium 2.0 policy helper module did not load.'); + } + const platformPubkey = platform.getPubkeyHex(); + const relays = getActiveRelays(splitRelayList(state.community?.relays || ''), { includeCoolingDown: true }); + const events = await queryRelays(relays, { + kinds: [30078], + authors: [platformPubkey], + '#d': [helpers.PREMIUM_POLICY_D_TAG || 'artstr:premium-policy:v1'], + limit: 5, + }, { timeoutMs: 4500 }); + const latest = (events || []) + .filter((event) => event?.content) + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + for (const event of latest) { + if (!NWC.verifyEvent(event)) continue; + const validated = helpers.validatePremiumPolicyEvent(event, { platformPubkey }); + if (!validated.ok) continue; + const stamp = helpers.buildPremiumPublishStamp(validated.policy, { + supportedSoftgateEpochs: Premium.SUPPORTED_EPOCHS, + }); + return { event, policy: validated.policy, stamp }; + } + 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.'); + } + 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 // buyers later. const myPubkey = state.community?.myPubkey; if (!myPubkey) throw new Error('Sign in with Nostr before publishing premium.'); + const premiumPolicy = await _loadPremiumPublishPolicy(); // Generate the preview FIRST so a too-large artboard bails before we // build any event. Decks get one watermarked JPEG per slide (capped) // so buyers can flip through what they'd unlock; everything else gets @@ -25809,9 +25844,18 @@ const coord = { creatorPubkey: myPubkey, dTag, schemaVersion: 5 }; // Encrypt the payload. The decrypted shape is the existing // loadProjectFromText-compatible JSON. - const envelope = await Premium.encryptPayload(payload, coord); + const envelope = await Premium.encryptPayload(payload, coord, { epoch: premiumPolicy.stamp.softgateEpoch }); envelope.payloadSchemaVersion = 5; - envelope.premiumMode = 'softgate-v1'; + envelope.premiumMode = premiumPolicy.stamp.premiumMode; + envelope.policy = { + eventId: premiumPolicy.event.id || '', + pubkey: premiumPolicy.event.pubkey || '', + d: window.ArtstrPremium2?.PREMIUM_POLICY_D_TAG || 'artstr:premium-policy:v1', + }; + envelope.softgate = { + ...(envelope.softgate || {}), + ...(premiumPolicy.stamp.envelopeSoftgate || {}), + }; // Single-image envelope for non-decks; multi-slide envelope for // decks. Buyers detect via `kind` and either render a paginated // viewer (decks) or the single watermarked JPEG (everything else). @@ -25840,7 +25884,6 @@ const zapMin = Math.max(10, Math.floor(Number(state.publish?.zapMin) || 100)); const platformPubkey = platform.getPubkeyHex(); const relayHint = (splitRelayList(state.community?.relays || '')[0]) || ''; - const epoch = envelope.softgate?.epoch || Premium.CURRENT_EPOCH; const tags = [ ['d', dTag], ['t', 'casewrap'], @@ -25851,9 +25894,8 @@ ['language', state.meta?.language || 'en'], ['client', 'Artstr Studio'], ['premium', 'true'], - ['premium-mode', 'softgate-v1'], - ['softgate-epoch', epoch], - ['encrypted', 'artstr-softgate-v1'], + ...premiumPolicy.stamp.tags, + ['encrypted', premiumPolicy.stamp.encryptedTag], ['zap-gate', 'true'], ['zap-min', String(zapMin)], ['zap-split', 'required'], diff --git a/src/premium2-helpers.mjs b/src/premium2-helpers.mjs index a115a71..e57468f 100644 --- a/src/premium2-helpers.mjs +++ b/src/premium2-helpers.mjs @@ -1,5 +1,7 @@ export const PREMIUM_POLICY_D_TAG = 'artstr:premium-policy:v1'; export const PURCHASE_COPY_PREFIX = 'artstr:purchase-copy:'; +export const PREMIUM_MODE_V15 = 'softgate-v1.5'; +export const PRIVATE_SNAPSHOT_ACTION = 'private-snapshot-required'; export function tagValue(event, name) { const tag = (event?.tags || []).find((t) => Array.isArray(t) && t[0] === name); @@ -34,6 +36,56 @@ export function validatePremiumPolicyEvent(event, { platformPubkey } = {}) { return { ok: true, policy }; } +export function buildPremiumPublishStamp(policy, { + now = Math.floor(Date.now() / 1000), + supportedSoftgateEpochs = [], +} = {}) { + if (!policy || typeof policy !== 'object' || policy.v !== 1) { + throw new TypeError('Premium policy v1 is required.'); + } + const softgateEpoch = policy.activeSoftgateEpoch || ''; + const claimEpoch = policy.activeClaimEpoch || ''; + const epochConfig = policy.epochs?.[softgateEpoch]; + if (!softgateEpoch || !claimEpoch || !epochConfig) { + throw new Error('Premium policy is missing the active epoch configuration.'); + } + if (epochConfig.status === 'closed') { + throw new Error(`Premium policy epoch ${softgateEpoch} is closed for new publishes.`); + } + if (supportedSoftgateEpochs.length && !supportedSoftgateEpochs.includes(softgateEpoch)) { + throw new Error(`Premium policy epoch ${softgateEpoch} is not supported by this client.`); + } + const minDays = Math.max(1, Number(policy.minClaimDays) || 1); + const maxDays = Math.max(minDays, Number(policy.maxClaimDays) || 180); + const requestedDays = Number(policy.defaultClaimDays) || 90; + const claimDays = Math.min(maxDays, Math.max(minDays, requestedDays)); + const claimUntil = Number(now) + (claimDays * 86400); + const premiumMode = PREMIUM_MODE_V15; + const postPurchaseAction = PRIVATE_SNAPSHOT_ACTION; + return { + premiumMode, + softgateEpoch, + claimEpoch, + claimUntil, + claimDays, + postPurchaseAction, + encryptedTag: epochConfig.softgateKdf || 'artstr-softgate-v1.5', + tags: [ + ['premium-mode', premiumMode], + ['softgate-epoch', softgateEpoch], + ['claim-epoch', claimEpoch], + ['claim-until', String(claimUntil)], + ['claim-policy', postPurchaseAction], + ['post-purchase-action', postPurchaseAction], + ], + envelopeSoftgate: { + claimEpoch, + claimUntil, + postPurchaseAction, + }, + }; +} + 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 1ef1e9d..ebb90f7 100644 --- a/tests/premium/premium2-helpers.test.mjs +++ b/tests/premium/premium2-helpers.test.mjs @@ -8,6 +8,7 @@ import { buildPurchaseCopyEventDraft, buildPurchaseCopyCoordinate, buildPurchaseCopyPlaintext, + buildPremiumPublishStamp, buildVaultV2Item, claimStatus, normalizeReceiptEvidence, @@ -39,6 +40,58 @@ test('validates admin policy event and rejects untrusted policy shapes', async ( assert.equal(validatePremiumPolicyEvent(wrongD, { platformPubkey }).reason, 'wrong_d_tag'); }); +test('builds automatic premium publish stamps from admin policy', async () => { + const valid = await fixture('policy-valid-active.json'); + const { policy } = validatePremiumPolicyEvent(valid, { platformPubkey }); + const stamp = buildPremiumPublishStamp(policy, { + now: 1783200000, + supportedSoftgateEpochs: ['2026-05', '2026-07'], + }); + + assert.equal(stamp.premiumMode, 'softgate-v1.5'); + assert.equal(stamp.softgateEpoch, '2026-07'); + assert.equal(stamp.claimEpoch, '2026-07'); + assert.equal(stamp.claimUntil, 1790976000); + assert.deepEqual(stamp.envelopeSoftgate, { + claimEpoch: '2026-07', + claimUntil: 1790976000, + postPurchaseAction: 'private-snapshot-required', + }); + assert.deepEqual(stamp.tags, [ + ['premium-mode', 'softgate-v1.5'], + ['softgate-epoch', '2026-07'], + ['claim-epoch', '2026-07'], + ['claim-until', '1790976000'], + ['claim-policy', 'private-snapshot-required'], + ['post-purchase-action', 'private-snapshot-required'], + ]); +}); + +test('rejects premium publish stamps for unsupported or closed policy epochs', async () => { + const valid = await fixture('policy-valid-active.json'); + const { policy } = validatePremiumPolicyEvent(valid, { platformPubkey }); + + assert.throws( + () => buildPremiumPublishStamp(policy, { supportedSoftgateEpochs: ['2026-05'] }), + /not supported/, + ); + + const closedPolicy = { + ...policy, + epochs: { + ...policy.epochs, + [policy.activeSoftgateEpoch]: { + ...policy.epochs[policy.activeSoftgateEpoch], + status: 'closed', + }, + }, + }; + assert.throws( + () => buildPremiumPublishStamp(closedPolicy, { supportedSoftgateEpochs: ['2026-07'] }), + /closed/, + ); +}); + 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 1d1a7cd..b1600f5 100644 --- a/tests/premium/premium2-index-integration.test.mjs +++ b/tests/premium/premium2-index-integration.test.mjs @@ -26,6 +26,28 @@ test('index page wires Premium 2.0 private-copy unlock flow', async () => { assert.match(html, /Private copy saved\. Syncing purchase vault/); }); +test('index page wires admin policy into premium publish stamping', async () => { + const html = await indexHtml(); + + assert.match(html, /async function _loadPremiumPublishPolicy/); + assert.match(html, /helpers\.validatePremiumPolicyEvent/); + assert.match(html, /helpers\.buildPremiumPublishStamp/); + assert.match(html, /NWC\.verifyEvent\(event\)/); + assert.match(html, /supportedSoftgateEpochs: Premium\.SUPPORTED_EPOCHS/); + assert.match(html, /const premiumPolicy = await _loadPremiumPublishPolicy\(\)/); + assert.match(html, /Premium\.encryptPayload\(payload, coord, \{ epoch: premiumPolicy\.stamp\.softgateEpoch \}\)/); + assert.match(html, /envelope\.premiumMode = premiumPolicy\.stamp\.premiumMode/); + assert.match(html, /\.\.\.premiumPolicy\.stamp\.tags/); + assert.match(html, /\['encrypted', premiumPolicy\.stamp\.encryptedTag\]/); +}); + +test('soft-gate crypto supports the active Premium 2.0 policy epoch', async () => { + const html = await indexHtml(); + + assert.match(html, /'2026-07': \[/); + assert.match(html, /const CURRENT_EPOCH = '2026-07'/); +}); + test('vault item stubs preserve Premium 2.0 private-copy fields', async () => { const html = await indexHtml(); const stubStart = html.indexOf('function _vaultItemStub(item)');