Skip to content

The DAM spin-off framework

When (and whether) NanaWall's media should move out of NanaSelect into a standalone DAM forked from seebod — the borrow-now / fork-later posture, the triggers that would flip it, and the integration path if it flips.

What it is

NanaSelect carries a media subsystem at Drupal scale (~18.8k assets after the #1094 dump bootstrap): a tagged, faceted, rights-governed library with AI-assisted enrichment. seebod is a standalone AI-powered DAM (Cloudflare Workers + Hono + D1 + R2, the same platform family) with a server-to-server Integration API and an embeddable picker, built to serve exactly this kind of consumer.

That overlap poses a strategic question: should NanaWall media spin off into a seebod-fork DAM that NanaSelect (and Drupal, and future NanaWall properties) consume — or stay a subsystem of NanaSelect? This document makes that call deliberate instead of accidental: it states the current posture, names the checkable triggers that would change it, and documents the integration path so a future fork is an execution exercise, not a design one.

The posture: borrow now, fork later (maybe never)

NanaSelect borrows seebod's patterns but keeps media in-app. Media stays a NanaSelect subsystem — same repo, same D1, same admin — while the parts of seebod that earned their shape are borrowed as patterns, not as a dependency. No seebod code is imported, no seebod service is called; the borrow is architectural.

Why this posture and not a fork today:

  • The consumer count is one. NanaSelect is the only application reading this library. A DAM's costs (a second service, auth between services, deployment, versioned API contracts) buy nothing until a second consumer exists.
  • Media is load-bearing for the product loop, not a product. Selection-relevant imagery (#1114), recommendation galleries, and rights gating (#1115) are wired into NanaSelect's recommendation spine — the shared facet vocabulary (system/attribute/factor/application/geography) is the point, and it lives here.
  • The bytes mostly aren't ours to manage. ~All dump-ingested assets are referenced (#384): the bytes stay at nanawall.com and render via images.nanawall.com; NanaSelect stores metadata + associations. A DAM that owns bytes (seebod's model — R2-stored, visibility-partitioned keys) solves a problem NanaSelect deliberately avoided.
  • Forking early forfeits velocity. In-app, a schema change and its UI land in one commit (the #1112–#1116 run shipped spine extension → AI tagging → retrieval → rights → faceting in days). Across a service boundary each of those becomes an API version negotiation.

What was borrowed vs. reimplemented

seebod patternWhere it lives in seebodWhat NanaSelect did (and where)
Faceted search pushed into SQL, facet options as dataapps/api/src/routes/filters.ts, access clauses in services/asset-access.tsBorrowed the pattern, reimplemented on drizzle/D1: searchLibrary + libraryFacetCounts (src/lib/server/admin/media.ts, #1116) — bounded pages, whitelisted sort, live counts, EXISTS probes on indexed columns
On-demand renditions with edge cachingroutes/assets.ts thumbnail transform → R2 write-back via CF ImagesBorrowed the shape, adapted to the referenced/imported split: displaySrc resolves referenced assets to images.nanawall.com renditions and imported bytes to the app transform path (src/lib/media/variants.ts, #384)
AI enrichment proposed, human acceptedseebod's AI description/tag generationReimplemented with a review gate: media_visual_tags proposed → accepted/rejected (#1113); accept promotes the proposal to a live media_tags classification row, source ai (#1805)
Rights/visibility gating what servesvisibility column partitioning R2 keys + queriesReimplemented as a rights posture: rights_status publish gate on every public read (#1115)
Asset-reference snapshot for external storageseebod-asset-ref-v1 (GET /assets/:id/reference)Not needed in-app — becomes relevant only at fork time (see integration path)

The borrow direction can also reverse: NanaSelect's facet-spine alignment (tags sharing the recommendation vocabulary) and the referenced-asset model are patterns a NanaWall fork of seebod would need and seebod doesn't have. The fork inherits from both parents.

Fork triggers

The posture flips when any of these named conditions is met and the review (below) confirms the trend. Each is checkable — a number or a yes/no, not a vibe.

#TriggerThresholdWhy this threshold
T1A second real consumerAny external system (Drupal, another NanaWall property, a partner portal) needs write or curated read access to the library — not just the public URLs it already getsThe moment two applications curate one library, the library needs its own service boundary, auth, and contract. This is the decisive trigger; the others are early warnings of it.
T2Asset scale> 100k assets, or D1 media tables dominating database size/limits~18.8k assets today sit comfortably in D1 beside the product DB. At ~5× growth the media tables start to constrain the product database's operational headroom (backup, migration time, D1 size limits) — the DAM's storage story should stop being coupled to NanaSelect's.
T3DAM feature surface beyond NanaSelect's needsSustained demand for ≥ 2 features that serve library management itself rather than selection — e.g. version history, ingest workflows for photographers, asset expiry/embargo automation, usage analytics across consumersFeatures that don't feed the recommendation loop don't belong in NanaSelect's admin. Two or more of them under sustained demand means a curation product is being built inside a selection product.
T4Curation team scale> 2 people whose primary role is media curation, or curation access needed for people who should not have NanaSelect admin accessNanaSelect's admin auth is one capability surface. A curation team needs roles, queues, and an audit surface of its own — seebod's unlimited-users posture fits that; NanaSelect's admin doesn't.
T5Byte ownership inversion> 50% of actively-served assets are app-owned bytes (imported into R2) rather than referenced from nanawall.comThe referenced model is what makes in-app media cheap. If NanaWall starts originating photography here (bytes in our R2, rights we must manage at origin), we are running a storage product and should run it as one.

Non-triggers, for the record: total asset count alone below T2; the existence of seebod (a capable landlord is not a reason to move); AI-tagging volume (the pass is cost-bounded and idempotent, #1113); Drupal continuing to serve referenced bytes (that is the designed steady state).

The integration path (when a trigger fires)

How a spun-off NanaWall DAM — a seebod fork — would serve NanaSelect and Drupal. Grounded in seebod's actual code (paths cited against ~/Sites/seebod at the time of writing) so the fork is an execution exercise, not a design one.

What the fork inherits from seebod

The Integration API (apps/api/src/routes/integration.ts, mounted at /api/integration; doc: seebod docs/integration-api.md) is the server-to-server surface. Auth is a bearer key (sb_live_…) enforced by middleware/integration-auth.ts — a key carries client_name (what distinguishes an integration key from an admin key), allowed_origins, and scopes: assets:read, assets:search, assets:upload, assets:write. Endpoints: GET /assets (paged list, limit ≤ 100), GET /assets/:id, GET /assets/search (text + tags), POST /assets/upload, POST /assets/:id/make-public, and — the one that matters most for consumers — GET /assets/:id/reference, which returns a compact seebod-asset-ref-v1 snapshot designed to be stored in the consumer's database.

The embeddable picker (apps/api/src/routes/integration-picker.ts) is a self-contained HTML page for iframe embedding. The postMessage contract: parent sends {type: 'seebod-picker-config', apiKey}; on confirm the picker fetches each selection's /reference and posts {type: 'seebod-picker-selection', assets: [...asset-ref-v1]} to the parent origin. A consumer embeds an iframe, listens for one message type, and stores the refs.

Rendition serving (apps/api/src/routes/assets.ts): originals stream from R2 by key; thumbnails transform on demand via the Cloudflare Images binding and write back to R2 (thumbnails/{id}_{size}.{ext}, immutable cache, format negotiated from Accept). R2 keys are visibility-partitioned (pub/…, u/{userId}/…, org/{orgId}/…) so public assets serve unauthenticated and CDN-cacheable.

Consumer mapping: peabod-www template → nanawall-www

seebod's documented consumer template (peabod-www in its integration doc) maps one-to-one onto NanaSelect-as-consumer:

Consumer concernseebod template (peabod-www)NanaSelect as nanawall-www consumer
Stored referenceseebod-asset-refs table + featured_image_ref columnsmedia_assets collapses to a ref store: source='dam', source_ref=<asset-ref-v1 id>, URLs from the ref — the exact seam #384 already built for referenced Drupal assets
Choosing assetspicker iframe + seebod-picker-selection listenerReplaces the LibraryBrowser source: the admin picks from the DAM picker; media_links/media_tags (the facet-spine associations) stay in NanaSelect — they are selection semantics, not library semantics
Renderingurls.thumbnails.{small,medium,large} from the refdisplaySrc gains a third branch beside referenced/imported: DAM rendition URLs. Callers don't change — the resolver seam (#384/#1116-verified) absorbs it
Authone scoped key per consumerassets:read + assets:search for the app; assets:upload,write only for the admin's curation path

Drupal as a second consumer is the same shape with a smaller surface: a read-scoped key, GET /assets/search by tag, and rendition URLs in place of images.nanawall.com ones. Nothing Drupal-specific is required of the DAM.

What the fork must add (gaps found in the survey)

The fork is not free; the survey found four things seebod does not have that NanaWall would need:

  1. Per-key origin enforcementallowed_origins is stored and parsed but not enforced in integration-auth.ts; CORS is a hardcoded allowlist. Multi-consumer NanaWall needs the per-key check wired.
  2. Per-consumer asset scoping — integration queries scope by visibility only; every key sees the same pool. Fine for one tenant, but a partner-portal consumer must not see embargoed assets: the #1115 rights posture belongs in the DAM at fork time.
  3. The referenced-asset model — seebod owns bytes; NanaWall's library is mostly images.nanawall.com references. The fork inherits NanaSelect's remote_url/import-on-demand seam or migrates bytes into DAM R2 (a T5 world).
  4. The facet spine — seebod's tags are free vocabulary. The system/attribute/factor/application axes and their controlled refs are NanaSelect's; the DAM needs at minimum tag-namespace support so spine tags round-trip through the picker.

Recommendation and review cadence

Recommendation (2026-07): stay in-app; do not fork. No trigger is met: there is one consumer (T1), 18.8k assets (T2), no sustained non-selection feature demand (T3), no dedicated curation team (T4), and the library is overwhelmingly referenced bytes (T5). The #1112–#1116 run demonstrated the in-app velocity the posture exists to protect. The correct present-tense investment is what this milestone already did: keep borrowing seebod's earned patterns and keep the two seams (displaySrc resolution, source/source_ref provenance) clean — they are the future fork's contact surface, and they are cheap to keep clean now.

Review cadence: re-evaluate the trigger table quarterly (with the regular roadmap review), and immediately on any of: a concrete request from a second consumer (T1 events don't wait for quarters), a dump re-sync or ingest that doubles asset count, or a NanaWall decision to originate photography in-app. Each review is one comment on the tracking issue (#1117) stating each trigger's current value against its threshold — five numbers and a yes/no, not an essay. If a review flips a trigger, the fork decision gets a business case (per the repo's PM hierarchy) before any code moves.


Related: Content & media (what the in-app media subsystem is), The Drupal relationship (why bytes are referenced, not copied), media tagging (the facet spine the DAM would need to honour).