Skip to content

Evaluate community multisite plugin (furelid/sonicjs-multisite-plugin) #830

@lane711

Description

@lane711

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Schema/code mismatch on field casing. Zod uses isDefault / isInitial; DB columns + service code use is_default / is_initial. Types and runtime disagree.
  7. 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.
  8. 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:

  1. Push tenant filtering down into core's content query layer (Drizzle where-clause), not response post-processing.
  2. Normalize domain to a hostname at validation/save time.
  3. Decide on one filter strategy (per-row siteId vs. per-collection registration) and implement end-to-end with migrations.
  4. Have the plugin loader auto-mount middleware/routes so consumers don't hand-wire in src/index.ts.
  5. Enforce declared permissions; gate cross-tenant admin access explicitly.

We could also reach out to the author to collaborate or upstream improvements.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions