From ed8e9f27389f0c1d406f21e165f3ef9f2d5b9a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:46:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(rest):=20=E6=B8=85=E6=89=AB=20REST=20?= =?UTF-8?q?=E7=BB=84=E5=90=88=E6=A0=B9=E7=9A=84=E6=A7=BD=E6=9F=A5=E6=89=BE?= =?UTF-8?q?=20=E2=80=94=E2=80=94=2016=20=E5=A4=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8C=96,=E5=A5=91=E7=BA=A6=E7=94=B1=20implements=20=E8=83=8C?= =?UTF-8?q?=E4=B9=A6=20(#4251=20B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4251 的 B4 批次:REST 组合根的全部槽查找擦除。`rest-api-plugin.ts`(15)与 `external-datasource-routes.ts`(1)改为传入槽的契约类型,棘轮基线 159 → 143 站点 / 34 → 32 文件,两个文件退出祖父名单。无行为变更。 `email` / `sharing` / `sharingRules` / `reports` / `approvals` / `external-datasource` 六个槽在 packages/spec 里本来就有契约,而各自 provider 注册进槽的那个类都写了 implements(`EmailService implements IEmailService` 等), 所以生产侧的形状由编译器每次构建校验,本文件只需具名 —— 即 #4404 用一份受检 claim 取代七个未受检 stand-in 的纪律。`auth` / `objectql` / `i18n` / `analytics` / `security` / `metadata` 取自 ServiceSlotContracts 台账;`objectql` 是 IObjectQLEngine 而非 IDataEngine,因为消费方够到的是完整引擎。 十处查找位于 `Promise` 的 provider 内,只改查找会在下一行把 契约重新擦掉(规则看不见的包装返回注解残留),故 provider 返回类型一并收窄。 三个无契约的槽分别用三种方式如实表达:`env-registry` 用 RestServer 构造函数 自己声明的 RestEnvRegistry(实参因此受检);`settings` 按 B2 决策给具名本地 surface(service-settings 可选,REST 层不能对它产生依赖),返回类型复用公开的 ResolvedSettingValue;`default-project` 只声明本文件读的那一个字段。服务存在性 探测的槽名是运行期参数,用 unknown —— 它只问槽里有没有东西,从不碰形状。 本批未发现死探测,如实记录而非暗示:逐一核对了 emailService.send、 authService.getApi/isAuthGateActive、svc.queryDataset、ql.transaction、六个 approval 动词、五个 security 方法与五个 federation 方法,全部命中真实成员与 真实 arity。 新增的钉子是运行期测试,这是刻意选择:packages/rest 的 tsconfig 排除 test 文件 且该包无 typecheck script,没有任何 tsc program 编译它们,写类型级断言会是 #5286 / #5449 付过代价的 phantom check。真正的风险是接线 —— provider 是二十参 构造函数的第 6..19 个位置参数、形状完全相同,接错槽处处可赋值且编译器看不见, 所以测试驱动每个 provider 并断言它取回自己那个槽注册的实例,钉住 boot 解析的 槽名集合,以及全部可选槽为空时的降级路径。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- ...slot-lookup-sweep-rest-composition-root.md | 65 +++++ .../rest/src/external-datasource-routes.ts | 19 +- .../src/rest-api-plugin-slot-lookups.test.ts | 223 ++++++++++++++++++ packages/rest/src/rest-api-plugin.ts | 126 +++++++--- scripts/slot-lookup-baseline.json | 2 - 5 files changed, 400 insertions(+), 35 deletions(-) create mode 100644 .changeset/slot-lookup-sweep-rest-composition-root.md create mode 100644 packages/rest/src/rest-api-plugin-slot-lookups.test.ts diff --git a/.changeset/slot-lookup-sweep-rest-composition-root.md b/.changeset/slot-lookup-sweep-rest-composition-root.md new file mode 100644 index 0000000000..d137a17697 --- /dev/null +++ b/.changeset/slot-lookup-sweep-rest-composition-root.md @@ -0,0 +1,65 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): sweep the REST composition root's slot lookups — 16 sites typed (#4251 B4) + +Batch B4 of the #4251 sweep: every service-lookup erasure in the REST +composition root. `rest-api-plugin.ts` (15) and `external-datasource-routes.ts` +(1) now pass the slot's contract type instead of annotating the result `any`; +the ratchet baseline drops **159 → 143 sites, 34 → 32 files**, and both files +leave the grandfather list. No behaviour change. + +**Every contract named here is evidenced by an `implements`.** `email`, +`sharing`, `sharingRules`, `reports`, `approvals` and `external-datasource` had +a written `packages/spec` contract all along, and the class each provider +registers into the slot declares `implements` on it (`EmailService implements +IEmailService`, `ExternalDatasourceService implements IExternalDatasourceService`, +…). So the compiler verifies the shape on the producer side on every build and +this file only has to name it — the #4404 discipline that replaced seven +unchecked local stand-ins with one checked claim. `auth`, `objectql`, `i18n`, +`analytics`, `security` and `metadata` come from the `ServiceSlotContracts` +ledger; `objectql` is `IObjectQLEngine`, not `IDataEngine`, because the consumer +reaches the full engine (the `transaction` probe behind the batch routes). + +**The wrapper return annotations went with them.** Ten of these lookups sit +inside `async (environmentId?) => Promise` providers, and +typing only the lookup would have re-erased the contract one line later — the +KNOWN RESIDUAL shape the rule documents and cannot see. Each provider now +returns its slot's contract. + +**Three slots have no contract, and say so three different ways rather than one +`any`.** `env-registry` is typed as `RestEnvRegistry`, the shape `RestServer`'s +own constructor declares for that parameter, so the argument is checked rather +than waved through. `settings` gets a named local surface (`SettingsReadSurface`) +following B2's decision for this slot — `service-settings` is optional, so the +REST layer must not depend on it — carrying the one method the platform consumes +(`get`, through `resolveLocalizationContext`'s cascade) with the public +`ResolvedSettingValue` as its return type. `default-project` gets a narrow slice +declaring only the field this file reads. And the service-existence probe, whose +slot name is a runtime argument, is `unknown`: it asks whether something +occupies the slot and never touches its shape, which is exactly what `unknown` +says and `any` does not. + +**No dead probe this batch — reported rather than implied.** Every earlier batch +in this line found one (#4361's `getMetaItem` on a service that never had it, +#4321's `registerInMemory`), so each probe the typed consumers make was checked +against its contract: `emailService.send`, `authService.getApi` / +`isAuthGateActive`, `svc.queryDataset`, `ql.transaction`, the six approval +verbs, the five security methods and the five federation methods all name real +members at real arities. The `external-datasource` route probes are now visibly +redundant-but-correct — the contract's methods are required, so `svc?.method` is +truthy whenever the service resolved, and the 503 path is reached only by the +service being absent, which is what it is for. + +The new pin is a runtime test, deliberately. `packages/rest` excludes its test +files from `tsconfig.json` and declares no `typecheck` script, so no tsc program +compiles them and a type-level assertion there would evaluate never — the +phantom-check shape #5286 / #5449 paid for. What is checkable is the wiring, and +that is the risk this change actually carries: the providers are positional +arguments 6..19 of a twenty-argument constructor, all with the same +`(environmentId?) => Promise` shape, so a provider resolving the wrong +slot is assignable everywhere and invisible to the compiler. The test drives +each provider and asserts it hands back the instance registered in ITS slot, +pins the exact set of slot names the boot resolves, and pins the degraded path +where every optional slot is empty. diff --git a/packages/rest/src/external-datasource-routes.ts b/packages/rest/src/external-datasource-routes.ts index 39a772718c..a8cdba48ac 100644 --- a/packages/rest/src/external-datasource-routes.ts +++ b/packages/rest/src/external-datasource-routes.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { IExternalDatasourceService, IHttpServer } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; @@ -62,9 +62,18 @@ export function registerExternalDatasourceRoutes( ): void { const ext = `${basePath}/datasources/:name/external`; - const externalService = (): any => { + /** + * The `external-datasource` slot's occupant (ADR-0015 §4.5). + * + * [#4251 B4] `IExternalDatasourceService`, which `ExternalDatasourceService` + * declares `implements` — so the five method names and arities this module + * probes are checked against the contract rather than asserted. Returns + * `undefined` when federation is not wired into the host; every route below + * answers 503 in that case, which is why the lookup is allowed to fail. + */ + const externalService = (): IExternalDatasourceService | undefined => { try { - return ctx.getService('external-datasource'); + return ctx.getService('external-datasource'); } catch { return undefined; } @@ -153,8 +162,8 @@ export function registerExternalDatasourceRoutes( if (!svc?.validateAll) return unavailable(res); try { const report = await svc.validateAll(); - const results = (report.results ?? []).filter((r: any) => r.datasource === req.params.name); - sendOk(res, { ok: results.every((r: any) => r.ok), results }); + const results = (report.results ?? []).filter((r) => r.datasource === req.params.name); + sendOk(res, { ok: results.every((r) => r.ok), results }); } catch (err) { refused(res, err); } diff --git a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts new file mode 100644 index 0000000000..e936ef838b --- /dev/null +++ b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4251 B4] The REST composition root's slot lookups, pinned at runtime. + * + * `rest-api-plugin.ts` resolves sixteen service slots and hands most of them to + * `RestServer` as lazily-invoked providers. B4 replaced the `any` on every one + * of those lookups with the slot's contract — a change that cannot alter + * behaviour, but CAN silently mis-wire it: the providers are positional + * arguments 6..19 of a twenty-argument constructor, all with the same shape + * (`(environmentId?) => Promise`), so a provider that resolves the + * wrong slot name is assignable everywhere and invisible to the compiler. + * + * Why a RUNTIME pin and not a type-level one. `packages/rest/tsconfig.json` + * excludes its `.test.ts` files and the package declares no `typecheck` script + * (it is a DEBT/TEST_DEBT ledger entry), so NO tsc program compiles this file. A + * `@ts-expect-error` or an `Assert< Equal< … > >` written here would evaluate + * never and stay green if it were deleted — the phantom-check shape AGENTS.md + * bans and #5286 / #5449 paid for. What IS checkable here is the wiring, so + * that is what this pins: + * + * 1. every provider resolves the slot it is NAMED for (the mapping the B4 + * types assert, verified against the registry), and + * 2. the exact set of slot names the boot asks for — so a retyped literal + * (`'sharingRules'` → `'sharing-rules'`) fails here rather than degrading + * one route to a permanent 501 in production. + */ + +import { describe, it, expect, vi } from 'vitest'; + +const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); + +// Capture RestServer's constructor arguments without registering ~hundreds of +// routes. Everything else in the module (RestEnvRegistry & co) stays real, so +// the plugin's imports resolve normally. +vi.mock('./rest-server.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + RestServer: class { + constructor(...args: unknown[]) { + captured.ctorArgs.push(args); + } + registerRoutes(): void { + /* routes are not under test here */ + } + }, + }; +}); + +const { createRestApiPlugin } = await import('./rest-api-plugin.js'); + +/** + * The provider arguments, by their position in the `RestServer` constructor. + * `slot` is the service name the provider must resolve — the claim each B4 type + * annotation makes, restated in a form the runtime can check. + */ +const PROVIDERS = [ + { index: 6, label: 'authServiceProvider', slot: 'auth' }, + { index: 7, label: 'objectQLProvider', slot: 'objectql' }, + { index: 8, label: 'emailServiceProvider', slot: 'email' }, + { index: 9, label: 'sharingServiceProvider', slot: 'sharing' }, + { index: 10, label: 'reportsServiceProvider', slot: 'reports' }, + { index: 11, label: 'approvalsServiceProvider', slot: 'approvals' }, + { index: 12, label: 'sharingRulesServiceProvider', slot: 'sharingRules' }, + { index: 13, label: 'i18nServiceProvider', slot: 'i18n' }, + { index: 14, label: 'analyticsServiceProvider', slot: 'analytics' }, + { index: 15, label: 'settingsServiceProvider', slot: 'settings' }, + { index: 17, label: 'securityServiceProvider', slot: 'security' }, + { index: 19, label: 'metadataServiceProvider', slot: 'metadata' }, +] as const; + +/** + * Every slot name the boot itself resolves, before any route runs. + * + * `external-datasource` is deliberately NOT here: its lookup lives inside a + * per-request closure (`external-datasource-routes.ts`), so registering the + * routes resolves nothing — the federation routes answer 503 per request when + * the service is absent rather than deciding it once at boot. + */ +const BOOT_SLOTS = [ + 'manifest', + 'http.server', + 'protocol', + 'kernel-manager', + 'env-registry', + 'kernel-resolver', + 'package', +] as const; + +function mockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** One distinguishable fake per slot, so "which slot did it read" is provable. */ +function allServices(): Record { + const services: Record = { + 'http.server': mockServer(), + protocol: { getDiscovery: vi.fn() }, + manifest: { register: vi.fn() }, + 'kernel-manager': { getOrCreate: vi.fn() }, + 'env-registry': { resolveByHostname: vi.fn() }, + 'kernel-resolver': { resolveKernel: vi.fn() }, + 'default-project': { environmentId: 'env_only' }, + package: { listPackages: vi.fn() }, + 'external-datasource': { validateAll: vi.fn() }, + }; + for (const { slot } of PROVIDERS) services[slot] = { __slot: slot }; + return services; +} + +function mockCtx(services: Record) { + const asked: string[] = []; + return { + asked, + ctx: { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + asked.push(name); + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }, + }; +} + +async function boot(services: Record) { + captured.ctorArgs.length = 0; + const { ctx, asked } = mockCtx(services); + const plugin = createRestApiPlugin(); + await plugin.init?.(ctx as never); + await plugin.start?.(ctx as never); + expect(captured.ctorArgs).toHaveLength(1); + return { args: captured.ctorArgs[0]!, asked, ctx }; +} + +describe('[#4251 B4] rest-api-plugin slot lookups', () => { + it('resolves each provider from the slot it is named for', async () => { + const services = allServices(); + const { args } = await boot(services); + + for (const { index, label, slot } of PROVIDERS) { + const provider = args[index] as (environmentId?: string) => Promise; + expect(typeof provider, `${label} must be wired at argument ${index}`).toBe('function'); + // The provider must hand back the instance registered in ITS slot — not + // a sibling's. Same shape for all of them, so only identity proves it. + await expect(provider('env_1'), `${label} must resolve '${slot}'`).resolves.toBe( + services[slot], + ); + } + }); + + it('passes the env-registry and default-environment seams as RestServer declares them', async () => { + const services = allServices(); + const { args } = await boot(services); + + // `envRegistry` is a plain instance, not a provider (constructor arg 4). + expect(args[4]).toBe(services['env-registry']); + // `defaultEnvironmentIdProvider` reads the one field this plugin declares + // on the `default-project` slot. + expect((args[5] as () => string | undefined)()).toBe('env_only'); + }); + + it('reports service presence without touching the occupant', async () => { + const services = allServices(); + const { args } = await boot(services); + const exists = args[16] as (name: string) => boolean; + + expect(exists('analytics')).toBe(true); + // An empty slot throws out of `getService`; the probe answers false rather + // than propagating. + expect(exists('nope-not-registered')).toBe(false); + }); + + it('asks for exactly the slots it declares, and no others', async () => { + const services = allServices(); + const { args, asked } = await boot(services); + + // Providers are lazy, so drive every one to make its lookup observable. + for (const { index } of PROVIDERS) { + await (args[index] as (environmentId?: string) => Promise)('env_1'); + } + (args[5] as () => string | undefined)(); + + const expected = new Set([ + ...BOOT_SLOTS, + 'default-project', + ...PROVIDERS.map((p) => p.slot), + ]); + expect(new Set(asked)).toEqual(expected); + }); + + it('degrades without optional slots — every provider answers undefined, no throw', async () => { + // Only the two slots `start()` hard-requires; every other lookup throws. + const services: Record = { + 'http.server': mockServer(), + protocol: { getDiscovery: vi.fn() }, + }; + const { args } = await boot(services); + + for (const { index, label } of PROVIDERS) { + const provider = args[index] as (environmentId?: string) => Promise; + await expect(provider('env_1'), `${label} must degrade to undefined`).resolves.toBeUndefined(); + } + expect(args[4]).toBeUndefined(); + expect((args[5] as () => string | undefined)()).toBeUndefined(); + expect((args[16] as (name: string) => boolean)('analytics')).toBe(false); + }); +}); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 57b4692c4e..6f9a086301 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -1,14 +1,72 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; -import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver } from './rest-server.js'; +import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver, RestEnvRegistry } from './rest-server.js'; import { RestServerConfig } from '@objectstack/spec/api'; import { registerPackageRoutes } from './package-routes.js'; import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; import type { PackageService } from '@objectstack/service-package'; -import type { IMetadataService } from '@objectstack/spec/contracts'; +import type { ResolvedSettingValue } from '@objectstack/spec/system'; +// [#4251 B4] Every slot this composition root resolves, named by its contract. +// The lookup already returns the slot's contract; annotating the result `any` +// switched that checking off while looking identical to code that has it. +// Each contract below is EVIDENCED by an `implements` on the class its provider +// registers into the slot (`EmailService implements IEmailService`, …), so the +// compiler verifies the shape on the producer side every build and this file +// only has to name it — the #4404 discipline that replaced seven unchecked +// local stand-ins with one checked claim. +import type { + IAnalyticsService, + IApprovalService, + IAuthService, + IEmailService, + II18nService, + IMetadataService, + IObjectQLEngine, + IReportService, + ISecurityService, + ISharingRuleService, + ISharingService, +} from '@objectstack/spec/contracts'; import { SysImportJob } from '@objectstack/platform-objects/audit'; +/** + * The `default-project` slot's occupant, as this file reads it. + * + * [#4251 B4] No contract and no entry in `ServiceSlotContracts`: the slot is + * host-provided, and no provider in this repo or in `cloud` registers it (the + * `createSingleEnvironmentPlugin` named in the comment below, and in five other + * comments across `runtime`/`rest`, exists in neither) — filed separately + * rather than widened here. So this declares only the ONE field this file + * reads, the #5195 narrow-slice discipline: `runtime`'s own reader declares the + * fuller `{ environmentId: string; orgId?: string }`, and `orgId` is not read + * here. A named slice is checked where an `any` was not — and when it is wrong, + * it says so, which `any` never does. + */ +interface DefaultEnvironmentSurface { + readonly environmentId: string; +} + +/** + * The `settings` slot's occupant, as the platform reads it. + * + * [#4251 B4] Named surface rather than a ledger entry, following the B2 + * decision for this slot: `service-settings` is OPTIONAL, so the REST layer + * must not acquire a runtime dependency on it, and its `SettingsService` + * declares no `implements` — there is no contract to name. The one method the + * platform consumes is `get`, through `resolveLocalizationContext`'s 4-tier + * timezone/locale/currency cascade; its return type is the PUBLIC + * `ResolvedSettingValue` from `@objectstack/spec/system`, so only the context + * argument is described structurally (`SettingsContext` is service-local). + */ +interface SettingsReadSurface { + get( + namespace: string, + key: string, + ctx?: { tenantId?: string; userId?: string }, + ): Promise>; +} + export interface RestApiPluginConfig { serverServiceName?: string; protocolServiceName?: string; @@ -97,9 +155,12 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // routes so a remote runtime node can dispatch every request // to the matching per-environment kernel without requiring callers // to know the environmentId. - let envRegistry: any; + // Typed as the shape RestServer's constructor declares for it + // (`RestEnvRegistry`), so the argument is checked rather than + // waved through — the `any` here made that parameter unverifiable. + let envRegistry: RestEnvRegistry | undefined; try { - envRegistry = ctx.getService('env-registry'); + envRegistry = ctx.getService('env-registry'); } catch (e) { // Not running in runtime/multi-environment mode — fine. } @@ -148,7 +209,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // lone project's kernel. const defaultEnvironmentIdProvider = (): string | undefined => { try { - const dp: any = ctx.getService('default-project'); + const dp = ctx.getService('default-project'); return dp?.environmentId; } catch { return undefined; } }; @@ -157,73 +218,77 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // single-kernel deployments where there is no kernelManager. // Multi-kernel paths look up auth via kernelManager.getOrCreate, // so this provider is the single-kernel fallback. - const authServiceProvider = async (_environmentId?: string): Promise => { + const authServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('auth'); + return ctx.getService('auth'); } catch { return undefined; } }; // ObjectQL resolver — single-kernel fallback so resolveExecCtx // can run sys_member / sys_user_permission_set lookups when // there is no kernelManager wired (e.g. `pnpm dev:crm`). - const objectQLProvider = async (_environmentId?: string): Promise => { + // [#4251 B3] `IObjectQLEngine`, not `IDataEngine`: the `objectql` + // slot is the SAME instance as `data` seen whole, and the consumer + // (`resolveExecCtx`, and the `transaction` probe behind the batch + // routes) reaches the full engine, not just the data plane. + const objectQLProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('objectql'); + return ctx.getService('objectql'); } catch { return undefined; } }; // Email service resolver — used by POST /email/send. Single- // kernel deployments resolve from the local kernel; multi- // tenant paths would resolve via kernelManager.getOrCreate. - const emailServiceProvider = async (_environmentId?: string): Promise => { + const emailServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('email'); + return ctx.getService('email'); } catch { return undefined; } }; // Sharing service resolver — used by /data/:object/:id/shares. - const sharingServiceProvider = async (_environmentId?: string): Promise => { + const sharingServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('sharing'); + return ctx.getService('sharing'); } catch { return undefined; } }; // Reports service resolver — used by /reports/* routes. - const reportsServiceProvider = async (_environmentId?: string): Promise => { + const reportsServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('reports'); + return ctx.getService('reports'); } catch { return undefined; } }; // Approvals service resolver — used by /approvals/* routes. - const approvalsServiceProvider = async (_environmentId?: string): Promise => { + const approvalsServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('approvals'); + return ctx.getService('approvals'); } catch { return undefined; } }; // Sharing-rule service resolver — used by /sharing/rules/* routes. - const sharingRulesServiceProvider = async (_environmentId?: string): Promise => { + const sharingRulesServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('sharingRules'); + return ctx.getService('sharingRules'); } catch { return undefined; } }; // i18n service resolver — used to localize view / action / object // metadata. Single-kernel fallback so labels and select options // get translated even without a full multi-tenant kernelManager. - const i18nServiceProvider = async (_environmentId?: string): Promise => { + const i18nServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('i18n'); + return ctx.getService('i18n'); } catch { return undefined; } }; // Analytics service resolver — used by /analytics/dataset/query // (ADR-0021 dataset preview/query). Returns undefined when no // analytics service is registered so the route fails cleanly (501). - const analyticsServiceProvider = async (_environmentId?: string): Promise => { + const analyticsServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('analytics'); + return ctx.getService('analytics'); } catch { return undefined; } }; @@ -231,9 +296,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // reference timezone/locale (localization manifest) through the 4-tier // cascade incl. the `OS_LOCALIZATION_TIMEZONE` env override. Returns // undefined when no settings service is registered (UTC default). - const settingsServiceProvider = async (_environmentId?: string): Promise => { + const settingsServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('settings'); + return ctx.getService('settings'); } catch { return undefined; } }; @@ -256,9 +321,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // /security/suggested-bindings routes and the D6 /security/explain // route (plugin-security). Returns undefined when plugin-security // is not mounted so the routes fail cleanly (501). - const securityServiceProvider = async (_environmentId?: string): Promise => { + const securityServiceProvider = async (_environmentId?: string): Promise => { try { - return ctx.getService('security'); + return ctx.getService('security'); } catch { return undefined; } }; @@ -276,8 +341,13 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // Single-env service-existence probe for nav capability gates // (ADR-0057 D10). Multi-env uses the per-request kernel instead. + // `unknown`, deliberately: the slot name is a runtime argument, so no + // contract can be named here — and this probe asks only whether + // SOMETHING occupies the slot, never touching its shape. `unknown` + // says exactly that and, unlike `any`, makes any future member + // access through this lookup a compile error instead of a silent one. const serviceExistsProvider = (name: string): boolean => { - try { return ctx.getService(name) != null; } catch { return false; } + try { return ctx.getService(name) != null; } catch { return false; } }; try { const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider); diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index b9fe535daa..2b4c1409d8 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -19,8 +19,6 @@ "packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts": 1, - "packages/rest/src/external-datasource-routes.ts": 1, - "packages/rest/src/rest-api-plugin.ts": 15, "packages/services/service-datasource/src/admin-routes.ts": 2, "packages/services/service-job/src/job-service-plugin.ts": 4, "packages/services/service-messaging/src/messaging-service-plugin.test.ts": 2,