Context
A community dev published a multisite/multitenant plugin: https://github.com/furelid/sonicjs-multisite-plugin
This issue captures an initial evaluation so we can revisit later — either to adopt ideas, collaborate with the author, or build something inspired by it. The repo was just created (2026-05-07), no stars, empty README, no license file (MIT only declared in the manifest). It ships as a SonicJS app + plugin folder rather than a publishable plugin package.
What it does
- Adds a
plugin_multisites table (id, name, domain, aliases JSON, is_default, is_initial, soft-delete).
/admin/multisite UI for site CRUD + a settings page.
active_site_id cookie drives admin filtering; host-header → site resolution drives the public API.
- Content gets a
siteId field (injected via mutated FormData) and is filtered post-response in middleware.
Strengths worth borrowing
- Reasonable plugin surface: lifecycle (install/activate/deactivate/uninstall), admin pages, menu items, routes, services, models, middleware.
- Idempotent install —
CREATE TABLE IF NOT EXISTS + indexes batched, plus a SQL migration as a fallback. Soft-delete via deleted_at.
- Cookie-based active-site selector is a simple admin UX pattern.
- Settings persisted into the core
plugins.settings JSON column — fits existing convention.
- Manifest/registry shape (auto-generated
manifest-registry.ts) is a clean pattern.
Significant correctness bugs
- Domain resolution is broken for the primary domain.
schemas/multisite.ts validates domain as z.string().url() (saves https://example.com), but tenant-api.middleware.ts does WHERE domain = ? against c.req.header('host') (host only). Primary lookups always miss and fall through to the alias scan. Aliases happen to work because that path uses new URL(a).hostname.
- Post-response JSON/HTML filtering breaks pagination and counts.
apiContentSiteFilterMiddleware and adminContentSiteFilterMiddleware slice items out of a response after core has counted/sorted/paginated. Page 1 of 20 may render 12 rows, totals are wrong, next-page can repeat or skip items. Needs to be a query-level filter, not a response rewriter.
- Half of the filter pipeline references a table that's never created.
tenant-content-filter.middleware.ts and content-site-injection.middleware.ts read/write plugin_multisite_collections, but neither the migration SQL nor the install/activate batch creates it. try/catch swallows the error silently — the feature is dead code in practice.
- Manifest permissions are decorative.
multisite:edit / multisite:delete are declared but only role === 'admin' is checked. No per-tenant access control — any admin can manage any tenant.
- Site-id injection trusts the cookie blindly.
site-id-injection middleware sets siteId from the active_site_id cookie on every POST to /admin/content regardless of whether that user is allowed to write to that site, and overwrites any siteId the form supplied.
- Schema/code mismatch on field casing. Zod uses
isDefault / isInitial; DB columns + service code use is_default / is_initial. Types and runtime disagree.
- Tests are wrong.
plugin.test.ts asserts plugin.name === 'multisite', but the actual id is 'multisite-tenant'. Same problem in integration.test.ts (manager.getStatus('multisite')). Tests would fail if run.
- Inconsistent siteId on collections.
pages has a hidden siteId field (via the showWhen: { value: '__never__' } hack); blog-posts does not — so blog filtering can't work.
Architectural concerns
- Two plugin definitions in one file (
src/plugins/multisite-tenant/index.ts): a PluginBuilder-based one that's commented out, and a literal Plugin object that's actually exported. Dead code, two sources of truth.
- Manual wiring in
src/index.ts — middleware and admin routes are mounted outside the plugin loader. Either the loader doesn't pick them up, or this is bypassing it. A real plugin shouldn't need its host app to import it explicitly.
- HTML string-rewriting for the admin UI (splitting on
<tr, regex UUID extraction, replacing anchor comments with menu HTML) is fragile — any Catalyst template change breaks it. Same for the menu injection middleware.
- Mutating Hono's parsed FormData to inject
siteId relies on internal Hono caching behavior, not a documented API.
- No caching on host→site lookup. Every API request hits D1; on a miss, it scans the full sites table and JSON-parses aliases per row. Won't scale.
domain is unique but aliases are not enforced unique across sites — a domain that's also another site's alias has ambiguous resolution.
- Cookie hardening:
active_site_id set with httpOnly: false and no Secure flag.
- README is empty, no install/usage docs, no license file in the repo.
Bottom line
Useful prototype that hits the right architectural touchpoints and is worth studying. As-is, not production-ready: primary-domain resolution doesn't match, tenant filtering runs after pagination, half the filter pipeline references a missing table, permissions aren't enforced, tests don't pass.
If we pursue first-party multisite, the right path is probably:
- Push tenant filtering down into core's content query layer (Drizzle where-clause), not response post-processing.
- Normalize
domain to a hostname at validation/save time.
- Decide on one filter strategy (per-row siteId vs. per-collection registration) and implement end-to-end with migrations.
- Have the plugin loader auto-mount middleware/routes so consumers don't hand-wire in
src/index.ts.
- Enforce declared permissions; gate cross-tenant admin access explicitly.
We could also reach out to the author to collaborate or upstream improvements.
Context
A community dev published a multisite/multitenant plugin: https://github.com/furelid/sonicjs-multisite-plugin
This issue captures an initial evaluation so we can revisit later — either to adopt ideas, collaborate with the author, or build something inspired by it. The repo was just created (2026-05-07), no stars, empty README, no license file (MIT only declared in the manifest). It ships as a SonicJS app + plugin folder rather than a publishable plugin package.
What it does
plugin_multisitestable (id, name, domain, aliases JSON, is_default, is_initial, soft-delete)./admin/multisiteUI for site CRUD + a settings page.active_site_idcookie drives admin filtering; host-header → site resolution drives the public API.siteIdfield (injected via mutated FormData) and is filtered post-response in middleware.Strengths worth borrowing
CREATE TABLE IF NOT EXISTS+ indexes batched, plus a SQL migration as a fallback. Soft-delete viadeleted_at.plugins.settingsJSON column — fits existing convention.manifest-registry.ts) is a clean pattern.Significant correctness bugs
schemas/multisite.tsvalidatesdomainasz.string().url()(saveshttps://example.com), buttenant-api.middleware.tsdoesWHERE domain = ?againstc.req.header('host')(host only). Primary lookups always miss and fall through to the alias scan. Aliases happen to work because that path usesnew URL(a).hostname.apiContentSiteFilterMiddlewareandadminContentSiteFilterMiddlewareslice items out of a response after core has counted/sorted/paginated. Page 1 of 20 may render 12 rows, totals are wrong, next-page can repeat or skip items. Needs to be a query-level filter, not a response rewriter.tenant-content-filter.middleware.tsandcontent-site-injection.middleware.tsread/writeplugin_multisite_collections, but neither the migration SQL nor the install/activate batch creates it.try/catchswallows the error silently — the feature is dead code in practice.multisite:edit/multisite:deleteare declared but onlyrole === 'admin'is checked. No per-tenant access control — any admin can manage any tenant.site-id-injectionmiddleware setssiteIdfrom theactive_site_idcookie on every POST to/admin/contentregardless of whether that user is allowed to write to that site, and overwrites any siteId the form supplied.isDefault/isInitial; DB columns + service code useis_default/is_initial. Types and runtime disagree.plugin.test.tsassertsplugin.name === 'multisite', but the actual id is'multisite-tenant'. Same problem inintegration.test.ts(manager.getStatus('multisite')). Tests would fail if run.pageshas a hiddensiteIdfield (via theshowWhen: { value: '__never__' }hack);blog-postsdoes not — so blog filtering can't work.Architectural concerns
src/plugins/multisite-tenant/index.ts): aPluginBuilder-based one that's commented out, and a literalPluginobject that's actually exported. Dead code, two sources of truth.src/index.ts— middleware and admin routes are mounted outside the plugin loader. Either the loader doesn't pick them up, or this is bypassing it. A real plugin shouldn't need its host app to import it explicitly.<tr, regex UUID extraction, replacing anchor comments with menu HTML) is fragile — any Catalyst template change breaks it. Same for the menu injection middleware.siteIdrelies on internal Hono caching behavior, not a documented API.domainis unique but aliases are not enforced unique across sites — a domain that's also another site's alias has ambiguous resolution.active_site_idset withhttpOnly: falseand noSecureflag.Bottom line
Useful prototype that hits the right architectural touchpoints and is worth studying. As-is, not production-ready: primary-domain resolution doesn't match, tenant filtering runs after pagination, half the filter pipeline references a missing table, permissions aren't enforced, tests don't pass.
If we pursue first-party multisite, the right path is probably:
domainto a hostname at validation/save time.src/index.ts.We could also reach out to the author to collaborate or upstream improvements.