npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

najm-theme

v0.2.1

Published

Managed runtime theming for Najm applications — appearance, theme presets, and branding assets with persistence, transport, and composable settings UI

Readme

najm-theme

Managed runtime theming for Najm applications: platform appearance, named theme presets, and branding assets — with the persistence, transport, and settings UI already written.

An application installs this when it wants administrators to change how the platform looks at runtime. What it supplies is configuration: the design it ships with, the paths to its built brand assets, who may change them, and where the sections appear. What it does not supply is a controller, a service, a repository, a DTO, a validator, a query key, an API client, a hook, an editor context, an upload cleanup job, or revision-conflict logic. Those live here.

Status: 0.1.0, pre-release. The public API is not frozen. It reaches 1.0.0 when two real consumers have passed production-build, database, browser, upgrade, and rollback acceptance — see Compatibility.


Contents


Install

bun add najm-theme

Peers, all optional except the ones a feature actually needs:

| Package | Needed when | |---|---| | najm-kit | always — this package builds on its design runtime and primitives | | drizzle-orm | always — the schemas are Drizzle tables | | najm-storage | features.assetUploads | | najm-mcp | features.mcp | | @tanstack/react-query | using najm-theme/react | | react, react-dom | using najm-theme/react | | next | using najm-theme/server/react | | sharp | optional — see Assets and storage |


Quick start

One directory and three edits. Nothing else in the application changes.

1. Create the factory themetheme/

theme/
├── index.ts
├── theme.json
├── sidebar-logo-expanded.(png|webp)
├── sidebar-logo-collapsed.(png|webp)
├── auth-logo.(png|webp)
└── auth-hero.(png|webp)
// theme/index.ts
import { defineTheme } from "najm-theme/theme";

export const appTheme = defineTheme(import.meta.url);

theme.json is a Najm design config — the same document the appearance editor saves, so a factory theme is always one the editor would accept back:

{
  "version": 1,
  "theme": { "preset": "light", "tokens": { "primary": "oklch(0.55 0.18 255)" } },
  "typography": { "fontSans": "Inter, system-ui, sans-serif" }
}

The four image names are fixed and all four are required. Each may be .png or .webp and exactly one of them: shipping both auth-logo.png and auth-logo.webp is a configuration error, because otherwise which one gets served would depend on directory order. Every file is read and validated when this module initializes — header bytes against the extension, size against the slot ceiling — so a renamed export or a missing hero fails a deployment with a file name in the message instead of leaving a hole in the sign-in page.

import.meta.url is what makes this portable. The working directory differs between a root script, a workspace script, a test runner, and a container, so a process.cwd() search resolves differently in each; the module URL is a property of the file that asked.

2. Compose the schemasrc/server/database/schema.ts

import { themeSchema } from "najm-theme/pg"; // or najm-theme/sqlite

export const schema = { ...appSchema, ...themeSchema };

Then generate a migration with your normal workflow. This package never issues CREATE TABLE at runtime.

3. Register the pluginsrc/server/theme.ts

import { theme } from "najm-theme/server";
import { appTheme } from "../../theme";
import { canManageTheme } from "./guards";

export const themePlugin = () =>
  theme(appTheme, {
    dialect: "pg",
    manage: [canManageTheme()],
  });

That is the whole registration. Appearance, branding, presets, and uploads are on; reads are public, because the sign-in page needs the theme and the logo before there is a session; the schema follows the dialect; the routes mount at /theme; diagnostics print a sanitized warning. Every one of those is an option — features, read, schema, basePath, diagnostics, limits, audit, storage, scope, and per-route guards — and none of them is something two consumers ever chose differently.

Register it after database(), and after storage() when uploads are on:

new Server()
  .use(database({ schema }))
  .use(storage({ guards: [isAuth()] }))
  .use(themePlugin())
  .base("/api");

which produces /api/theme/appearance, /api/theme/branding, and /api/theme/presets. The package serves the factory images too, at /api/theme/branding/factory/<slot>.<hash>.<ext> — there is no public path for the application to publish and no static handler for it to configure. The hash is the file's own content, so a deploy that changes a logo changes its URL and the one-year immutable cache in front of it is honest.

Upload ceilings (limits.logoBytes, limits.heroBytes) are policy for what an administrator may upload later. The factory files are checked when defineTheme runs, against the package defaults — 512 KB for a logo, 2 MB for the hero — so raise those with defineTheme(import.meta.url, { limits }) if a build genuinely ships something larger.

4. Mount the UI — anywhere in the application

"use client";

import { NThemeSettings, NThemeSettingsProvider } from "najm-theme/react";
import "najm-theme/styles.css";

export function ThemeSettingsPage() {
  return (
    <NThemeSettingsProvider onPersisted={() => router.refresh()}>
      <NThemeSettings />
    </NThemeSettingsProvider>
  );
}

The settings client already defaults to the standard /api/theme mount, so no baseUrl is needed for a normal application. Pass one only when the mount differs.

What each entry is for

najm-theme            universal contracts and pure helpers
najm-theme/contracts  the same surface, named explicitly
najm-theme/theme      defineTheme() — the factory theme directory loader
najm-theme/server     the plugin, its configuration, the audit and error types
najm-theme/server/react  the React Server Component bootstrap adapter
najm-theme/pg         PostgreSQL Drizzle tables
najm-theme/sqlite     SQLite Drizzle tables
najm-theme/react      providers, transport, and composable components
najm-theme/styles.css package-owned styles, on top of najm-kit/theme.css

najm-theme/theme is separate from najm-theme/server for one reason: your theme/index.ts is imported by the backend and by the React Server Component facade, and that entry carries no controller, no Drizzle, and no decorator, so importing it from a layout does not pull a plugin graph into your Next server bundle. najm-theme/server re-exports defineTheme for backend-only code.

Two rules hold across the map, and both are enforced by tests:

  • No client-capable entry statically imports a server entry. The browser condition of najm-theme/server/react resolves to a module that throws, so a Client Component importing it fails at build time rather than shipping the application's internal fetcher to a browser.
  • No server entry imports the najm-kit root barrel. That barrel reaches the whole component library; importing it from a route handler resolves react-hook-form under the react-server condition and fails the build. Server code uses najm-kit/server.

Configuration

theme(appTheme, {
  manage: ThemeGuardDecorator[];     // required — who may change the theme
  read?: ThemeGuardDecorator[];      // omitted: reads are public
  features?: Partial<NajmThemeFeatures>;  // default: all on except mcp
  database?: string;                 // named database, default "default"
  dialect?: "pg" | "sqlite";         // default "pg"; picks the built-in schema
  schema?: ThemeSchema;              // override the built-in tables
  basePath?: string;                 // default "/theme"
  scope?: ThemeScopeResolver;        // default: everything is "platform"
  brandingSlots?: BrandingSlotDefinition[];  // default: the four standard slots
  guards?: ThemeRouteGuards;         // per-route, wins over manage/read
  storage?: ThemeStorageConfig;
  audit?: ThemeAuditSink;
  diagnostics?: false | ThemeDiagnosticSink;  // default: sanitized console.warn
  limits?: {
    logoBytes?: number;              // upload ceiling for the three logo slots
    heroBytes?: number;              // upload ceiling for the hero slot
    appearance?: Partial<ThemeAppearanceLimits>;
    maxPresets?: number;
    allowBuiltInPresetDeletion?: boolean;
  };
  resolveActorId?: (user: unknown) => string | null;
})

Rules the plugin enforces at registration, not at first request:

  • manage is required. One list rather than three: every consumer that had manageAppearance, manageBranding, and managePresets put the same guard in all three, and the split invited a deployment where presets were administrable and branding was not. guards still separates them when an application genuinely means to.
  • Reads are public unless you pass read. An anonymous visitor needs the theme and the logo to render the sign-in page; supplying read makes both authenticated instead.
  • Presets are never public. read does not reach them; listing them falls back to manage unless you pass guards.readPresets.
  • The definition is required for every enabled resource. Its factory design is read per request and its failure is not caught: a factory theme that cannot be built is a broken build, and a second fallback would hide it behind a page that merely looks unstyled.
  • Limits may be widened only within package maxima. A design is parsed on every uncached server render, so an unbounded one is a denial-of-service vector against the application itself.

theme(config) with features, publicRead, guards, and factory: { appearance, branding } callbacks still resolves, so an application can adopt 0.2.0 and migrate in a separate change. It cannot serve factory assets — that needs a definition — and it keeps the old slot inheritance, where sidebarLogoCollapsed and authLogo fall back to sidebarLogoExpanded. Do not maintain both: there is no configuration in which the two are equally supported.

Scope

Every row is keyed by a scope. A single-platform application never thinks about it — the default resolver answers "platform". An application that later grows tenants supplies its own resolver and no table, index, or route changes:

scope: async ({ request }) => resolveTenantFromHost(request.headers.get("host")),

Scope identifiers are validated before they reach a query, a storage namespace, or a URL. That validation is what stops a resolver returning "../other" from becoming a cross-tenant read.


Database

Three tables, exported per feature and as a convenience composition:

import {
  appearanceSchema,   // najm_theme_appearance
  brandingSchema,     // najm_theme_branding
  themePresetSchema,  // najm_theme_presets
  themeSchema,        // all three
} from "najm-theme/pg";

An application that enables only Appearance spreads appearanceSchema and gets one table rather than two it never writes to.

najm-theme/pg and najm-theme/sqlite are column-for-column equivalent — same names, nullability, constraints, indexes, and public behaviour. A parity test compares the two definitions structurally, so drift fails in CI rather than in whichever consumer picked the other database.

Notable columns:

| Column | Why it is like that | |---|---| | design_config nullable | null means "on the factory design" — a real state, distinct from an empty design and from a missing row. Reset writes it deliberately. | | revision positive int | Increments by exactly one per committed mutation; a check constraint keeps it positive in both dialects. | | updated_by_actor_id text | No foreign key to an auth table. Attribution stays available when najm-auth is installed without making auth mandatory, and a deleted user does not cascade a scope's theme away. | | slot_config JSON | Custom managed references only. Inherited and factory values resolve at read time, so shipping a new default logo changes every scope that has not overridden it. |


Routes

Below basePath (default /theme):

GET    /theme/appearance                       public read
GET    /theme/appearance/config                administrative read
PUT    /theme/appearance                       save
POST   /theme/appearance/reset                 restore the factory design

GET    /theme/presets                          list (never public)
POST   /theme/presets                          create
POST   /theme/presets/:id/apply                apply to appearance
DELETE /theme/presets/:id                      delete

GET    /theme/branding                         public read
GET    /theme/branding/config                  administrative read
PUT    /theme/branding                         save the slot map
POST   /theme/branding/reset                   restore the factory assets
POST   /theme/branding/assets/:slot/:fileName  upload a candidate (binary)
GET    /theme/branding/assets/:fileName        serve a committed asset
DELETE /theme/branding/assets/:fileName        discard a candidate
POST   /theme/branding/assets/reconcile        sweep unreferenced assets
  • Public reads return only the resolved values and a revision. No provenance, no slot metadata, no storage internals.
  • GET /theme/branding/assets/:fileName follows the public read decision, not the management one: it is how a browser fetches the logo on the sign-in page.
  • Mutations are named actions, never generic setters. Reset is POST .../reset rather than PUT { designConfig: null }, because a setter that means "discard everything" when handed the right value is one that gets called by accident.
  • Upload endpoints are REST/binary only. Image bytes never travel as base64 in a JSON tool call.

Branding slots

Branding is a registry, not four columns. The package ships four standard slots:

| Key | Kind | Falls back to | |---|---|---| | sidebarLogoExpanded | logo | factory value | | sidebarLogoCollapsed | logo | inherits sidebarLogoExpanded | | authLogo | logo | inherits sidebarLogoExpanded | | authHeroImage | hero | factory value |

Resolution order per slot: managed asset → factory value → declared fallback. inheritFrom recurses through the same order, which is what makes "upload one logo and both marks update" true rather than requiring the same file twice.

Registering another slot is configuration in one application and needs no DDL anywhere:

brandingSlots: [
  ...STANDARD_BRANDING_SLOTS,
  {
    key: "emailHeader",
    kind: "image",
    labelKey: "app.branding.emailHeader",
    maxBytes: 256 * 1024,
    acceptedMimeTypes: ["image/png"],
    previewAspect: "wide",
  },
],

The UI renders it from the server response, so no component changes either. Give it a label through the provider's labels prop or your own catalog.

SVG is not accepted by default, and it is the omission most likely to be questioned. An SVG is a document: it can carry <script>, an <image href> that phones home, and an XML external entity, and served from your own origin as a top-level document it runs with your origin's privileges. Accepting one safely means parsing and sanitizing it. An application that has done that work can add image/svg+xml to its own slot definition.


Assets and storage

Uploads go through najm-storage, into a namespace scoped per scope (theme-branding-<scopeId>), so one tenant cannot reference another's file at the storage layer rather than through a check that has to be right every time.

The lifecycle has one rule that shapes all of it: the database decides what is real. A file exists in storage from the moment it is uploaded, but it is only a branding asset once a committed slot_config row references it. So:

  • the file write always precedes the commit;
  • every file delete always follows one;
  • nothing unlinks inside a database transaction.

That ordering makes the failure modes survivable. An upload that is never saved leaves an unreferenced file, which reconciliation collects. A save that commits and then fails to delete what it replaced leaves an unreferenced file — same outcome, and the save stays durable. The reverse order would leave a committed row pointing at a file that no longer exists: a broken logo on every page that nobody can fix from the settings screen, because the row looks fine.

Validation, in order: byte ceiling → magic-byte probe → declared type must agree with the bytes → slot accepts that type → dimension and pixel bounds → optional normalization → byte ceiling again on what will actually be stored. The dimension check reads the header, so a 40 KB PNG claiming 30000×30000 is rejected without allocating the 3.6 GB it wanted.

Normalization re-encodes through Sharp when it is installed. That is what turns "the magic bytes say PNG" into "this is a PNG": a decode/re-encode round trip drops trailing payloads, malformed ancillary chunks, and embedded metadata — including the EXIF GPS coordinates in a logo somebody exported from a phone. Sharp is an optional dependency; without it the probe, the MIME agreement check, and the dimension bounds still run. Set storage.normalize: false to turn it off explicitly.

Committed file names are UUIDs, never the uploader's. They are served with Cache-Control: public, max-age=31536000, immutable and X-Content-Type-Options: nosniff, using the MIME type re-derived from the stored bytes — a client can name which asset a slot uses, never what type it is. Immutable caching is safe precisely because the name is content-independent: a replacement is a different URL, so nothing has to expire for it to appear.

Reconciliation deletes only files that are both unreferenced and older than storage.orphanGraceMs (default 24 hours, minimum 1 hour). Both conditions are load-bearing: unreferenced alone would delete the upload an administrator made ninety seconds ago and is about to save, and old alone would delete the logo on every page. Factory assets live in your build output, not in this namespace, and are never reachable by any of it.


Revisions and conflicts

Appearance and branding each carry a revision that increments by exactly one per committed mutation. A client sends back the revision it was editing, and the write commits only if that is still current.

Two administrators with the settings sheet open is the normal case, not the exotic one. Without a revision the second save silently discards the first — including a preset the first one just applied. With it, the second save answers 409 with code THEME_REVISION_CONFLICT, and the UI offers a reload.

The revisions are separate: replacing a logo has nothing to do with editing a colour token, and one shared counter would make each mutation invalidate the other's open editor.

Every write is a compare-and-swap — the expected revision is in the WHERE clause — on top of a SELECT … FOR UPDATE on PostgreSQL. The compare-and-swap is what makes the guarantee hold at any isolation level and in both dialects; the row lock just moves the contention to the read.

Applying a preset takes expectedRevision and goes through the same appearance lock as a save. Giving it a weaker story would make it the way to clobber somebody.


React

Provider

NThemeSettingsProvider owns the whole feature state machine: queries, canonical query keys, mutations, drafts, dirty tracking, candidate uploads, revision conflicts, cache invalidation, and the immediate hand-off to Najm Kit's runtime providers so an edit is visible before it is saved.

It is deliberately not called NajmThemeProvider — Najm Kit already owns that name for the rendering runtime. The two are different objects: the kit's decides what the page looks like right now, this one decides what gets persisted. This nests inside it and replaces nothing.

<NThemeSettingsProvider
  language="fr"
  labels={{ "theme.settings.title": "Apparence" }}
  initialData={serverSnapshot}
  onPersisted={() => router.refresh()}
>
  {children}
</NThemeSettingsProvider>

No client is needed: the settings client already defaults to the standard /api/theme mount that the RSC bootstrap reads from. A custom or remote mount is the only reason to pass one:

<NThemeSettingsProvider client={{ baseUrl: "/api/theme-v2" }}>
  {children}
</NThemeSettingsProvider>

Requires a QueryClientProvider above it. Default consumers never call a hook from this package; useNThemeSettings is exported for a surface you build yourself.

Components

NThemeAppearanceSettings   NThemeSettingsActions      NThemeSettingsStatus
NThemeBrandingSettings     NThemeSettingsSaveButton   NThemeSettings
NThemePresetSettings       NThemeSettingsResetButton
  • No component creates its own provider or query client.
  • All sections share one provider's draft and revision state.
  • Each works alone when its dependencies are enabled, and renders nothing when they are not — including outside a provider entirely.
  • Appearance and branding save as independent requests. A branding failure is never reported as though the appearance save had rolled back, when it committed and is live.
  • Preset selection previews in memory; nothing persists until Apply in the legacy section flow or Save in the standard shared-action flow.
  • Every component takes className, label overrides, and a disabled prop.
  • Hiding a control is never the authorization boundary. Capabilities drive presentation; the guard on the route decides.

Composition

A custom tabbed sheet using the same compact interaction as the ready-made composite:

<NThemeSettingsProvider client={themeClient}>
  <NSheet open={open} onOpenChange={setOpen} icon={Palette} title="Theme">
    <NTabs
      items={[
        { value: "presets", label: "Saved themes", content: <NThemePresetSettings showApplyAction={false} /> },
        { value: "appearance", label: "Appearance", content: <NThemeAppearanceSettings showFileActions={false} /> },
        { value: "branding", label: "Branding", content: <NThemeBrandingSettings /> },
      ]}
    />
    <NThemeSettingsActions display="compact" showFileActions showDiscard={false} />
  </NSheet>
</NThemeSettingsProvider>

A standalone page:

<NPageLayout>
  <NThemeSettingsProvider client={themeClient}>
    <NThemeSettings />
  </NThemeSettingsProvider>
</NPageLayout>

One feature in a dialog:

<NThemeSettingsProvider client={themeClient} features={{ branding: true }}>
  <NDialog open={open} onOpenChange={setOpen} title="Branding">
    <NThemeBrandingSettings />
    <NThemeSettingsActions resources={["branding"]} />
  </NDialog>
</NThemeSettingsProvider>

features on the provider narrows what a page shows. It can never widen past what the backend registered — the routes behind it would not exist.

Styles

@import "najm-kit/theme.css";
@import "najm-theme/styles.css";

A Tailwind v4 source file, compiled by your build. It carries a @source directive so the utilities this package's components use survive that build. Layout uses logical properties throughout, so the surface mirrors under dir="rtl" without a second stylesheet.


Server rendering

najm-theme/server/react configures najm-kit/server/react; it does not implement a second cache. One small module in your application binds this frontend to this backend:

// src/lib/serverTheme.ts
import "server-only";

import { appTheme } from "../../theme";

const serverTheme = appTheme.react({
  getServer: async () => (await import("@app/server")).server,
});

export const loadServerTheme = serverTheme.load;
export const loadServerAppearance = serverTheme.loadAppearance;
export const loadServerBranding = serverTheme.loadBranding;

Call .react() once, at module scope, in a module the whole app imports. Calling it inside a layout, a page, or a component builds a fresh memoization entry per call and shares nothing — which looks like it works and quietly costs one round trip per component.

That module is the one file this package cannot delete for you, and it is deliberate: which server this frontend talks to is not something a package can know. What it does delete is everything that used to sit around it — the fetch, the envelope unwrap, the validation, the factory design, the branding map, the route prefix, and the per-resource independence.

The route prefix defaults to /api/theme, and the fallback branding URLs move with it. The bootstrap attaches the factory map to the branding it returns, so the React tree does not need a separate prop, a literal route, or a factory callback to render the chain. A legacy consumer may override basePath with another absolute prefix, while malformed relative, query, hash, or traversal paths fail where they are written. Fallbacks emit a sanitized console.warn by default; pass a custom onDiagnostic for application observability, or onDiagnostic: false to silence it.

getServer accepts a lazy Fetch-compatible server and is the normal same-process Najm integration. Use fetcher instead when the theme backend is remote or needs custom request construction. Supplying both, or neither, fails immediately. A frontend with no factory directory of its own — a separate deployment against a remote theme backend — uses createReactThemeBootstrap({ fetcher, factory }) and supplies the two values itself.

Then publish the resolved marks once, near the root, and render them by slot:

// app/layout.tsx
import { NThemeBrandingProvider } from "najm-theme/react";
import { loadServerTheme } from "@/lib/serverTheme";

const { appearance, branding } = await loadServerTheme();

<NThemeBrandingProvider branding={branding}>
  {children}
</NThemeBrandingProvider>
<NThemeImage slot="sidebarLogoExpanded" alt="Acme" className="h-8 w-auto" />
<NThemeImage slot="sidebarLogoCollapsed" alt="Acme" className="h-8 w-8" />
<NThemeImage slot="authLogo" alt="Acme" className="mx-auto h-10 w-auto" />
<NThemeImage slot="authHeroImage" alt="" fill />

NThemeImage renders what the server resolved — the managed upload if there is one, the factory file otherwise — and continues the same chain in the browser: an asset that 404s falls back to the factory file, and a slot whose every candidate fails renders nothing rather than a broken-image glyph. The factory map is on the branding the bootstrap returned, so the consumer passes no map, no path, and no mount.

Rules:

  • Root, auth, first-login, and nested layouts share one snapshot per render.
  • Separate requests never share a snapshot or a transient failure; a transient outage is retried on the next request.
  • Appearance and branding fall back independently: a branding outage never discards a perfectly good theme.
  • Do not wrap this in a module Map, a module promise, unstable_cache, "use cache", Redis, or any durable cache. Every one of them leaks one visitor's render into another's.
  • Saving updates the client providers immediately; a refresh or a later navigation observes the next server snapshot.
  • React Server Components only. Route handlers, server actions, and scripts have no request cache for cache() to write into, so they should call the endpoints directly.

Localization

English, French, Arabic, and Spanish ship in the package and serve both the API response messages and the UI labels. A parity test compares every catalog against English key by key — a missing translation does not fail at runtime, it just prints English inside an Arabic sheet, which is far easier to ship than to notice.

Labels resolve in this order:

  1. a labels override on the provider,
  2. the application's own translator (t), if it has an entry,
  3. the package catalog for the active language,
  4. English,
  5. the key itself.

The override wins over the translator on purpose: an override is a deliberate, component-level decision ("call it Branding here"), while a translator is a catalog it may not even know about.

Contribute the catalogs to najm-i18n if you would rather route everything through your own translator:

import { THEME_LOCALES } from "najm-theme/server";

MCP

With features.mcp, the same services are exposed as tools: theme_appearance_get, theme_appearance_reset, theme_presets_list, theme_preset_apply, and theme_branding_get. Register mcp() before theme().

Uploads are deliberately absent — a branding image as base64 inside a JSON tool call is megabytes of encoded bytes through a protocol built for text, in a transcript that is frequently logged.

Tools operate on the default scope. A tenant-aware installation should leave mcp off until it has decided how an agent names a tenant; silently defaulting to platform in a multi-tenant deployment would be the wrong answer, not a missing one.


Optional peers

najm-storage (for assetUploads) and najm-mcp (for mcp) are the only two packages this one reaches at runtime without importing. They are resolved from the container by symbol:

| Peer | Token | Aliased by | |---|---|---| | najm-storage | Symbol.for('najm:storage:service') | storage()StorageService | | najm-mcp | Symbol.for('najm:mcp:registry') | mcp()McpRegistryService |

This is a correctness requirement, not a packaging preference. A class works as a DI token only while every participant holds the same constructor. This package ships as dist, so import 'najm-storage' here resolves through node_modules; an application in a monorepo commonly maps the same specifier to src. Those are two module instances with two constructors of the same name — and resolving the wrong one does not fail. The container builds a second service, and the symptom arrives much later: uploads written through a storage service the application never configured, MCP tools registered into a registry nothing serves. Both were live defects here, found by running the Playground rather than the unit tests.

Symbol.for is keyed by string in a process-wide registry, so every copy produces the identical symbol, and each plugin aliases its symbol to its own class. The strings are declared in src/server/peers.ts rather than imported — importing them would load a peer's module graph to read a value whose entire purpose is to be independent of which copy is loaded — and pinned against the peers' real exports by test/server/peers.test.ts.

If a peer is missing, the error names the feature that required it and the registration to add. If a peer is present but fails while constructing, its own error propagates unchanged; this package does not translate it into "plugin not registered" and send you to inspect a plugin list that is already correct.


Migrating from a local implementation

If your application already has its own appearance/branding/preset modules, the cutover is a data move, not a rewrite.

  1. Compose the package schema beside your existing tables and generate a new migration. Do not edit deployed migrations.
  2. Copy the data in: design config, appearance revision, branding custom paths, branding revision, presets, built-in markers, creator attribution, and timestamps. Preserve the original revisions where valid, so in-flight clients fail with a clean conflict rather than silently overwriting.
  3. Do not drop the legacy columns in the same release. Rollback means reverting reads and writes to the legacy code while untouched legacy data remains available — not a destructive reverse migration.
  4. Compare projections before switching reads, for every scope, revision, preset, custom asset, and resolved fallback.
  5. Keep exactly one authoritative write path during cutover. Compatibility reads may fall back temporarily; dual writes need a separately reviewed transactional bridge and a removal date.
  6. Disable cleanup jobs until the cutover is accepted, and keep the package's storage paths readable throughout the rollback window.
  7. Then delete the local modules. These specifically should not survive: appearance*/branding*/themePreset* controllers, services, repositories, DTOs, validators; API clients; query keys; useAppearance/useBranding/ useThemePresets; the branding editor context; asset candidate and orphan cleanup; optimistic revision comparison; preset slug generation; and any copy of the package's locale messages.

An application-specific adapter is acceptable only when it translates a real host contract. It must not reproduce a package algorithm or become a permanent compatibility layer without an explicit removal issue.


Failure modes

| What happens | What the package does | |---|---| | Stored design fails validation | Serves the factory design, keeps the stored revision, emits appearance.invalid-stored-config. The revision is deliberate: a client editing against it still gets a clean conflict rather than overwriting a row nobody could read. | | Stored slot map has an unregistered slot | Drops that entry, keeps the page up, emits branding.invalid-slot-config. A slot removed in a deploy must not fail every page. | | A preset's design no longer validates | Omitted from the list with preset.invalid-design; applying it is refused. Returning it would hand the client a design that would be rejected at apply. | | Post-commit asset cleanup fails | The save stays committed; asset.cleanup-failed is emitted. Reporting it as a failed request would invite a retry of a mutation that already landed. | | Audit sink throws (non-transactional) | The mutation stays committed; audit.sink-failed is emitted. | | Factory design or branding throws | Propagates. A factory value that cannot be built is a broken build, and the only useful behaviour is a loud one. |

Every diagnostic carries a package-authored summary and a normalized error string — never a stored value, an uploaded byte, a response body, a cookie, an authorization header, or a storage credential. The payloads that go wrong here are exactly the interesting ones, and a log aggregator is not where they belong.


Compatibility

  • najm-kit ≥ 2.9.0 — this package builds on its published contracts and does not move NajmDesignConfig, the theme runtime, or any primitive out of it. najm-kit must never depend on najm-theme.
  • Node 20+, Bun 1.2+, React 18+, Next.js 14+, Drizzle 0.45+.
  • PostgreSQL and SQLite are both first-class and tested for parity.
  • assetUploads requires najm-storage ≥ 2.2.0, and mcp requires najm-mcp ≥ 2.1.0 — the releases that alias STORAGE_SERVICE and MCP_REGISTRY to their services. The peerDependencies ranges enforce this. Against an older peer the feature fails at boot with a message naming the missing registration — loudly, not silently, but it does not work.

Versioning. Pre-1.0 releases may change the public API with a changelog entry. 1.0.0 is not declared until two real consumers pass production-build, database, browser, upgrade, and rollback acceptance.

License

MIT