|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { serviceUnavailableMessage } from '@objectstack/spec/system'; |
| 4 | +import type { HttpDispatcherResult } from '../http-dispatcher.js'; |
| 5 | +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; |
| 6 | + |
| 7 | +/** |
| 8 | + * The one answer a dispatcher domain gives when its route is mounted but |
| 9 | + * nothing implements the capability behind it — **501**, carrying the same |
| 10 | + * remedy sentence discovery reports for that slot. |
| 11 | + * |
| 12 | + * ## The distinction this encodes |
| 13 | + * |
| 14 | + * Two different facts were being answered with whatever each domain happened |
| 15 | + * to reach for. They are not the same fact, and `mcp` is the one domain that |
| 16 | + * already told them apart: |
| 17 | + * |
| 18 | + * - **The route is not there.** `/mcp` when the server is disabled for this |
| 19 | + * environment; `/analytics` when the service is unserveable, because |
| 20 | + * `dispatcher-plugin` gates the *mount* and never registers those paths |
| 21 | + * (#4000). Asking for a path the server does not expose is a **404**, and |
| 22 | + * the host's own router says so. Nothing here overrides that. |
| 23 | + * |
| 24 | + * - **The route is there; the implementation is not.** Every domain mounted |
| 25 | + * unconditionally. The request reached a handler — it simply has nothing to |
| 26 | + * delegate to. That is **501 Not Implemented**, and it is what this helper |
| 27 | + * is for. |
| 28 | + * |
| 29 | + * ## What it replaces |
| 30 | + * |
| 31 | + * `return { handled: false }` looked like a neutral "not mine", but the |
| 32 | + * dispatcher plugin's single exit turns it into `404 ROUTE_NOT_FOUND` with the |
| 33 | + * hint *"No handler matched this request. Check the API discovery endpoint for |
| 34 | + * available routes."* Both halves are false: a handler did match, and |
| 35 | + * discovery — correctly — does not list the route, so the hint sends the |
| 36 | + * caller to a page that will not mention it. An operator reads that as a |
| 37 | + * routing bug and goes looking for one that does not exist. |
| 38 | + * |
| 39 | + * `/ui` said **503**, which claims the condition is temporary. An uninstalled |
| 40 | + * MetadataPlugin does not become installed by retrying. |
| 41 | + * |
| 42 | + * ## Why the message comes from `spec` |
| 43 | + * |
| 44 | + * `serviceUnavailableMessage` is the same sentence `services.<slot>.message` |
| 45 | + * carries in discovery (#4093 follow-up), so the 501 body and the discovery |
| 46 | + * entry cannot drift into naming different remedies — and a caller who hits |
| 47 | + * the wall gets the fix without a second round trip. |
| 48 | + * |
| 49 | + * @param slot the `CoreServiceName` key, NOT the route segment — `/notifications` |
| 50 | + * is served by the `notification` slot, and the remedy is looked up |
| 51 | + * by slot. |
| 52 | + */ |
| 53 | +export function capabilityUnavailable( |
| 54 | + deps: DomainHandlerDeps, |
| 55 | + slot: string, |
| 56 | + /** |
| 57 | + * Overrides the shared sentence. Only for slots whose provider cannot be |
| 58 | + * named by `CORE_SERVICE_PROVIDER` — it is verified against workspace |
| 59 | + * packages, so a real provider that ships outside this repo (`ai`) has no |
| 60 | + * entry there and would otherwise be described as "nothing ships". |
| 61 | + */ |
| 62 | + message?: string, |
| 63 | +): HttpDispatcherResult { |
| 64 | + return { handled: true, response: deps.error(message ?? serviceUnavailableMessage(slot), 501) }; |
| 65 | +} |
0 commit comments