Appearance
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 pattern | Where it lives in seebod | What NanaSelect did (and where) |
|---|---|---|
| Faceted search pushed into SQL, facet options as data | apps/api/src/routes/filters.ts, access clauses in services/asset-access.ts | Borrowed 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 caching | routes/assets.ts thumbnail transform → R2 write-back via CF Images | Borrowed 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 accepted | seebod's AI description/tag generation | Reimplemented 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 serves | visibility column partitioning R2 keys + queries | Reimplemented as a rights posture: rights_status publish gate on every public read (#1115) |
| Asset-reference snapshot for external storage | seebod-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.
| # | Trigger | Threshold | Why this threshold |
|---|---|---|---|
| T1 | A second real consumer | Any 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 gets | The 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. |
| T2 | Asset 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. |
| T3 | DAM feature surface beyond NanaSelect's needs | Sustained 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 consumers | Features 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. |
| T4 | Curation team scale | > 2 people whose primary role is media curation, or curation access needed for people who should not have NanaSelect admin access | NanaSelect'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. |
| T5 | Byte ownership inversion | > 50% of actively-served assets are app-owned bytes (imported into R2) rather than referenced from nanawall.com | The 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 concern | seebod template (peabod-www) | NanaSelect as nanawall-www consumer |
|---|---|---|
| Stored reference | seebod-asset-refs table + featured_image_ref columns | media_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 assets | picker iframe + seebod-picker-selection listener | Replaces 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 |
| Rendering | urls.thumbnails.{small,medium,large} from the ref | displaySrc gains a third branch beside referenced/imported: DAM rendition URLs. Callers don't change — the resolver seam (#384/#1116-verified) absorbs it |
| Auth | one scoped key per consumer | assets: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:
- Per-key origin enforcement —
allowed_originsis stored and parsed but not enforced inintegration-auth.ts; CORS is a hardcoded allowlist. Multi-consumer NanaWall needs the per-key check wired. - 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.
- The referenced-asset model — seebod owns bytes; NanaWall's library is mostly
images.nanawall.comreferences. The fork inherits NanaSelect'sremote_url/import-on-demand seam or migrates bytes into DAM R2 (a T5 world). - 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).