From f4a858e88f853cc7c73914f16f9d70de1517811c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 31 May 2026 21:28:05 -0400 Subject: [PATCH] Linked Designs: attribution p-tags + link-tags at publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per LINKED_DESIGNS_FEATURE.md §10.2, host designs that contain linked layers now emit: ['p', , , 'design-contributor'] per distinct contributor ['link', , ] per distinct linked ref Implementation: a new _buildLinkedAttributionTags helper drives the existing linkedDesigns.walkLinkedTree to collect contributor pubkeys + refs in one pass (cycle- and depth-3-safe via the resolver). The host author's pubkey is filtered from contributors. Walker errors degrade to empty tag arrays without failing the publish. Tags are spliced into the public publish path and the premium publish path. Private publishes skip attribution entirely — tags are unencrypted, so emitting them would leak the link graph through what is meant to be a feed-invisible event. Encrypted sources resolve to null payloads in Phase 1, so their authors silently don't get p-tagged — which is the right Phase 1 behavior until Phase 2 brings encrypted-link support. Unit tests at /tmp/test_attributionTags.js (12 cases) cover dedupe, self-filtering, malformed-pubkey rejection, empty walks, and walker failure modes. --- src/index.html | 55 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index 31126df..10575c9 100644 --- a/src/index.html +++ b/src/index.html @@ -6646,6 +6646,41 @@ return { resolveLinked, walkLinkedTree, walkContributors, clearCache, peek, keyFor, _parseAddressCoord, _cacheKey }; })(); + // Walk the host's linked tree to collect (a) distinct contributor + // pubkeys (excluding the host author — they're already the event + // signer) and (b) distinct linked refs. Returns the two tag + // arrays ready to splice into a kind-30078 tag set per + // LINKED_DESIGNS_FEATURE.md §10.2: + // ['p', , , 'design-contributor'] + // ['link', , ] + // Resolves each linked layer through linkedDesigns.walkLinkedTree + // (already cycle- + depth- + byte-capped). Encrypted sources + // return null payloads, so their authors silently don't get + // p-tagged — which is what we want for Phase 1. + async function _buildLinkedAttributionTags(payload, myPubkey) { + const contributors = new Set(); + const refs = new Set(); + try { + await linkedDesigns.walkLinkedTree(payload, { + onLink: ({ spec, entry }) => { + const pk = entry?.sourceEvent?.pubkey; + if (pk && /^[0-9a-f]{64}$/i.test(pk) && pk !== myPubkey) { + contributors.add(pk); + } + if (spec?.ref) refs.add(spec.ref); + }, + }); + } catch (err) { + console.warn('[linked-designs] attribution build failed', err); + } + const relayHint = (splitRelayList(state.community?.relays || '')[0]) || ''; + const contributorTags = Array.from(contributors).map( + (pk) => ['p', pk, relayHint, 'design-contributor'] + ); + const linkTags = Array.from(refs).map((ref) => ['link', ref, relayHint]); + return { contributorTags, linkTags }; + } + // Pre-publish hook. Linked layers carry render-only fields // (lastResolvedAt, lastResolvedSize, transient fallbackPreview) // that are session-local and should not ship in the published @@ -15459,6 +15494,16 @@ // in the published event. Strip them once here, before the path // branches into private/premium/public publish. stripLinkedDesignEphemera(payload); + // Attribution chain (LINKED_DESIGNS_FEATURE.md §10.2). Resolve the + // linked tree once and gather contributor `p` tags + `link` tags + // for the published event. Skipped for private publishes to + // avoid leaking the link graph through unencrypted tags. + const myPubkey = state.community?.myPubkey || ''; + let attributionTags = []; + if (state.visibility !== 'private') { + const built = await _buildLinkedAttributionTags(payload, myPubkey); + attributionTags = [...built.contributorTags, ...built.linkTags]; + } // Private: encrypt and publish a feed-invisible event instead of the // normal public, tagged one. if (state.visibility === 'private') { @@ -15475,7 +15520,7 @@ // preview JPEG, with the §5.1 tag set. Soft-gate; honest about the // limitation in the publish-modal copy. if (state.publish?.zapGate) { - const signed = await _publishPremiumDesign(payload, mode, typeTag); + const signed = await _publishPremiumDesign(payload, mode, typeTag, attributionTags); const label = isEditing() ? `Updated "${state.meta.title}" (premium)` : `Premium "${state.meta.title}"`; await finalizePublishedTemplateEvent(signed, { label }); if (isEditing()) exitEditMode(); @@ -15489,7 +15534,8 @@ const dTag = isEditing() ? state.editingDTag : `${mode}:${dBase}`; const tags = [ ['d', dTag], - ...getTemplateTags(payload, typeTag) + ...getTemplateTags(payload, typeTag), + ...attributionTags, ]; const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(payload) }; const signed = await publishCommunityEvent(evt); @@ -15503,7 +15549,7 @@ // 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 _publishPremiumDesign(payload, mode, typeTag) { + 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. @@ -15590,6 +15636,9 @@ // consumers don't depend on the client constant. ['zap', myPubkey, relayHint, String(10000 - PLATFORM_FEE_BPS)], ['zap', platformPubkey, relayHint, String(PLATFORM_FEE_BPS)], + // Linked-design attribution (LINKED_DESIGNS_FEATURE.md §10.2). + // Empty when the host has no linked layers. + ...attributionTags, ]; const evt = { kind: 30078,