feat(modules): publish the installed-module list at /api/v1/public/modules
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m33s
PR Checks / bot-install (pull_request) Successful in 8m45s

Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.

Four decisions, and what is absent from the payload is most of the design:

* started modules only. A module that is disabled or failed to load is
  ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
  client renders a site without that capability rather than advertising one
  that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
  the admin Modules screen, and the reason is an exception string from inside
  core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
  (3.1.3), so the browser is handed the tag rather than a URL to fetch. This
  endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
  amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
  /public/version, so a client can still feature-detect during maintenance.

It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.

910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:03:22 -05:00
parent 85f563fc16
commit 291c30f6ff
8 changed files with 483 additions and 0 deletions

View File

@@ -11620,6 +11620,30 @@
}
}
},
"/api/v1/public/modules": {
"get": {
"tags": [
"Public"
],
"summary": "Installed modules (id, version, capabilities)",
"description": "The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.",
"responses": {
"200": {
"description": "The started modules",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicModules"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/pages/{id}/preview/{token}": {
"get": {
"tags": [
@@ -17787,6 +17811,131 @@
}
}
},
"PublicModules": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated."
},
"properties": {
"type": "object",
"properties": {
"modules": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/PublicModule"
}
}
}
}
}
}
},
"PublicModule": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One started module, as published to anonymous clients."
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "uo"
},
"description": {
"type": "string",
"example": "Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>)."
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Ultima Online"
},
"description": {
"type": "string",
"example": "Human label."
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "1.0.0"
},
"description": {
"type": "string",
"example": "The module's own version (semver). Unrelated to the API version."
}
}
},
"capabilities": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "shard"
}
}
}
}
}
}
}
}
},
"Brand": {
"type": "object",
"properties": {