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 enforcement — allowed_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).