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