Linked Designs spec: tighten before implementation

Second-pass review surfaced eight issues worth pinning down before
we start coding. Each addressed in-place rather than as a TODO so
the implementation has a clear target:

1. §5.1 — Persisted vs in-memory fields. The host's serialized
   `content` was implicitly inflating with `fallbackPreview`,
   `lastResolvedAt`, `lastResolvedSize` from the initial schema.
   Now explicit: only `kind` / `ref` / `mode` / `pinnedEventId` /
   `pinnedSnapshot` persist; the rest are runtime-only and stripped
   at publish time. A linked layer footprint is ~110 bytes serialized.
2. §6.1 — Pinned + source deletion. New section covering the case
   where the source author kind-5-deletes a pinned event. Adds a
   `pinnedSnapshot` field that the renderer prefers-live-falls-back-
   to-snapshot, plus the existing `fallbackPreview` for visual-only
   fallback. Default = pin without snapshot, user opts in for
   permanence.
3. §8.1 — Sync vs async render decision. The renderer is synchronous
   today; this section explicitly picks "sync placeholder + async
   upgrade" over "block render" or "pre-fetch all". DOM tagging +
   abort-on-detach behaviour documented so the implementation
   doesn't end up re-deciding mid-build.
4. §8.2 — Autosave + cold start. localStorage restore loses in-memory
   resolver state, so on `restoreAutosave` we kick off the resolver
   for every linked layer in the background.
5. §10.3 — Embedded → linked attribution: corrected an inaccurate
   claim. Embedded layers don't carry source-author metadata, so
   "Publish + link" credits the CURRENT user, not whoever originally
   authored the embedded content. Action copy should warn explicitly.
6. §12.1 — Premium-link access check: clarified that the gate is the
   host viewer's purchase-vault entry (not technical decryption
   capability, since the soft-gate KDF is deterministic).
7. §13 — Added a per-tree cap (1 MB total resolved payload per host
   render) on top of the per-event cap. Depth-3 fan-out could
   theoretically multiply fetches; the tree cap stops a coordinated
   DOS at the host level.
8. §11.5 — Picking initial layer dimensions for a new linked layer
   (sync resolver lookup with a 1.5 s timeout, falls back to 16:9
   default). Plus a link-creation modal preview so the user can
   confirm what they're linking before commit.

Also tightened the Phase 1 acceptance criteria around serialized
size: explicit budgets for 10-link host events and verification that
in-memory fields are stripped at publish.
This commit is contained in:
thePR0M3TH3AN committed 2026-05-31 16:38:26 -04:00
1 parent 87dd9426c2
commit f8cf8570f2
1 file changed
+157 -17
+157 -17
View File
@@ -110,15 +110,42 @@ Linked design layer:
design: {
kind: 'linked', // disambiguates from embedded
ref: '30078:<creatorPubkey>:<dTag>', // NIP-01 addressable coord
mode: 'cover', // hint for pre-fetch render
mode: 'cover', // hint: source-design template mode
pinnedEventId: null, // optional, locks to a specific event id
fallbackPreview: 'data:image/jpeg;base64,...', // optional cached preview
lastResolvedAt: 1748656800000, // ms timestamp of last successful fetch
lastResolvedSize: { w: 1920, h: 1080 }, // hint for layout
pinnedSnapshot: null, // optional, see §6
fallbackPreview: null, // optional, see "persistence" below
lastResolvedAt: null, // optional, in-memory ms timestamp
lastResolvedSize: null, // optional, in-memory { w, h }
},
}
```
### 5.1 Persisted vs in-memory fields
The host event's `content` is what gets published to relays, so every
field there counts toward the per-event size budget. To keep linked
layers small:
- **Persisted (serialized to host event):** `kind`, `ref`, `mode`,
`pinnedEventId`, `pinnedSnapshot` (only when pinning, see §6).
- **In-memory only (stripped at publish time):** `lastResolvedAt`,
`lastResolvedSize`, `fallbackPreview`. These are populated by the
resolver after a successful fetch and used for runtime UI (timestamp
display, layout hints, offline fallback) but never written into the
serialized layer when the host publishes.
A pre-publish hook in `_publishPremiumDesign` and the regular publish
path strips the in-memory fields from `design` before signing. A linked
layer's serialized footprint is then roughly:
`{kind:'linked', ref:'30078:<pubkey>:<dTag>', mode:'cover', pinnedEventId:null}`
— ~110 bytes including overhead. 50 linked layers = ~5.5 KB, well under
any relay's content cap.
`fallbackPreview` is the one exception that's *optionally* persisted —
the user can tick "Cache a fallback preview" on the link-options panel
to embed a small JPEG (subject to the existing premium-preview 50 KB
cap) that survives source deletion. Off by default.
Embedded design layer (unchanged from today, made explicit for
contrast):
@@ -171,6 +198,29 @@ opt-in.
don't propagate until the host author manually un-pins or re-pins
to a newer event.
### 6.1 Pinned + source deletion
A subtle but important case: if the source author tombstones the
pinned event (kind-5 delete), relays will discard it and the pinned
event id won't resolve anywhere. The pin no longer protects against
disappearance.
Two complementary mitigations:
1. The pin-mode toggle also offers a checkbox **"Embed snapshot for
permanence"** that captures the resolved payload into
`design.pinnedSnapshot` (full design JSON). The renderer prefers
the live fetched event, falls back to `pinnedSnapshot` if the
event is gone. This restores the old embed-style survivability at
the cost of host-event size.
2. The `fallbackPreview` field (§5.1) is the lower-cost option for
visual-fidelity-only fallback: a small JPEG that renders when the
source disappears but isn't editable.
Default for new pinned layers: pin without snapshot. The user
explicitly opts in to snapshot for designs they want to outlive the
source.
The toggle is visible on every linked-design layer's right-panel.
Switching states is non-destructive; flipping back to "always latest"
just nulls `pinnedEventId`.
@@ -231,6 +281,47 @@ host design opened
renderDesignObjectIntoElement(el, { design: { payload } })
```
### 8.1 Sync vs async render
`render()` and `renderDesignObjectIntoElement` are synchronous today.
A linked layer can't return its content synchronously on first encounter
(relay query is async). Three patterns considered:
1. **Block render** on resolver — bad UX, stalls every layer behind the
first network round-trip.
2. **Pre-fetch all linked layers** before any render — predictable
state but visibly delays first paint.
3. **Sync render with placeholder, async upgrade** — initial render
shows a "⟳ Loading…" or `fallbackPreview` placeholder, kicks off the
resolver, then mutates the rendered DOM node when the resolver
resolves. *This is the chosen pattern.*
Implementation shape:
- `renderDesignObjectIntoElement(el, layer)` synchronously renders the
placeholder + tags the DOM element with `data-linked-pending`.
- The resolver's promise resolution finds that DOM element by layer id
and calls `renderDesignPayloadPreview(el, payload, opts)` to replace
the placeholder. Self-aborts if the element left the DOM mid-flight
(covers the case where a re-render swaps it out).
- Cached resolutions (session cache hit) skip the placeholder phase:
the renderer reads from cache synchronously and renders the payload
immediately. So the placeholder only appears on the very first fetch
(or after a manual refresh).
Trade-off accepted: per-layer loading flicker on cold open. Mitigated
by parallel resolver kick-off across all linked layers + the optional
`fallbackPreview` for visually-stable cold loads.
### 8.2 Autosave + cold start
Artstr autosaves `state` to localStorage on every change. A linked
layer's in-memory fields (`lastResolvedAt`, `lastResolvedSize`,
`fallbackPreview` when not opted-in to persist) are lost on tab
reload. On `restoreAutosave`, the resolver kicks off in the background
for every linked layer so the editor's first paint shows placeholders
that fill in within a few hundred ms (cache + relay round-trip).
Resolver lives in a `linkedDesigns` IIFE alongside the existing
`Premium` and purchase-vault helpers in `src/index.html`. Exposes:
@@ -307,9 +398,22 @@ let users find designs they've contributed to.
### 10.3 Embedded → linked conversions
When the user runs "Publish + link" on an embedded layer (see §11.3),
the embedded layer's original `pubkey` is preserved as the new
source author. Attribution survives the conversion.
When the user runs "Publish + link" on an embedded layer (see §11.4),
the embedded payload is published as a fresh kind-30078 under the
**current user's** pubkey (Artstr can't sign on behalf of the original
creator). The replacement linked layer therefore credits the current
user, not whoever first authored the embedded content. Today's
embedded layers don't carry source-author metadata, so we have no
provenance to preserve forward.
In practice the conversion is useful for layers the *current user*
authored manually but later wants to extract as reusable. For
content originating elsewhere, the conversion will credit the wrong
person — the action should warn explicitly in copy ("This publishes
the layer's contents under your Nostr identity. Don't use this for
designs you didn't make."). A future enhancement could capture the
original author at embed time so the chain survives, but that's out
of scope for this arc.
---
@@ -364,7 +468,26 @@ Pipeline:
the new address.
Lets a user organically refactor a self-contained sub-design into
a reusable linked asset.
a reusable linked asset. Important attribution caveat in §10.3 —
the published copy goes under the current user's pubkey, so this
should not be used for layers that originated from a third party.
### 11.5 Picking initial layer dimensions
`addDesignLayerFromPayload` today reads the embedded payload's aspect
ratio to choose initial layer w/h. A linked layer has no payload yet
when it's created (resolve is async).
Solution: the layer-add path runs a synchronous resolver lookup first
(blocks UI ~< 1 s on cold relays). If resolution succeeds, the
initial layer uses the source aspect like an embed would. If
resolution fails or times out (1.5 s), the layer is added at a
default 1920×1080 (16:9) aspect, with a toast suggesting the user
"Refresh the layer when the source is available" — they can resize
manually afterward.
The link-creation modal also previews the source design at choose
time so the user knows what they're linking before commit.
---
@@ -385,12 +508,18 @@ ref, not the full sub-payload).
### 12.1 Premium / private design boundary (Phase 2)
When a linked layer points at a premium design, the host viewer's
unlock state controls render:
**purchase-vault entry** controls render — NOT the soft-gate
decryption itself, since the soft-gate KDF is deterministic from
public material and any browser could technically decrypt without
payment (§ PREMIUM_DESIGNS_FEATURE.md). The access check is
"does `findUnlockedEntry(eventId, address)` find an entry for the
linked source?":
- Unlocked → render the decrypted payload from the viewer's purchase
vault.
- Locked → render a placeholder ("🔒 Linked premium design — unlock
to render") with an unlock CTA inline.
- Vault entry present → render the decrypted payload (already in the
cache from the vault).
- No vault entry → render a placeholder ("🔒 Linked premium design —
unlock to render") with an inline unlock CTA that runs the normal
premium-unlock flow against the linked source's address.
For private designs (`payload.visibility === 'private'`), Phase 2
checks the share-list at fetch time; rendering only happens for the
@@ -409,8 +538,14 @@ placeholder.
from an arbitrary third party.
- Reject layer types that don't pass the existing
`migrateLegacyLayerTargets` allowlist.
- Cap fetched payload size at 256 KB per linked event to avoid pulling
in adversarially huge sub-designs.
- **Per-event cap**: cap fetched payload size at 256 KB per linked
event to avoid pulling in adversarially huge sub-designs.
- **Per-tree cap**: cap *total* resolved payload bytes for a single
host render at 1 MB. Depth-3 fan-out can theoretically multiply
fetches (one root linking to many designs each linking to many);
the tree cap stops a coordinated DOS at the host level. When the
cap is hit, subsequent linked layers in the same render render the
fallback placeholder and emit a console warning.
- Depth cap (§9) prevents fetch-storm DOS.
- Cycle detection (§9) prevents stack-blowing infinite loops.
- No external URL fetching introduced — only relay queries.
@@ -495,8 +630,13 @@ placeholder.
- An embedded layer + its linked-twin layer render identically when
pointing at the same source.
- Layer panel shows 🔗 prefix + creator name.
- Host event published with linked layers stays under typical relay
size limits (verified with a 10-link example).
- A host event with 10 linked layers serializes to under 5 KB of
layer overhead (link refs ~ 110 bytes each) and publishes without
hitting any of the common relay caps (Damus / nos.lol / Primal /
Snort / nostr.band — all accept ≥ 64 KB content).
- In-memory fields (`lastResolvedAt`, `lastResolvedSize`,
non-opted-in `fallbackPreview`) are stripped from the serialized
layer at publish time so they never inflate the host event.
---