From e062231ccbcc87ad8ef6e3bd5d3e031e47f487c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:43:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20sweep=20the=20plugin=20composit?= =?UTF-8?q?ion=20roots'=20slot=20lookups=20=E2=80=94=2035=20sites=20typed,?= =?UTF-8?q?=20two=20alias-only=20HTTP=20reads=20fixed=20(#4251=20B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch B5 of the #4251 sweep: the seven remaining `packages/plugins/*` composition roots. Every service-lookup result that had been erased to `any` now carries the slot's contract, so the compiler checks what each plugin actually calls on the service it resolved. Ratchet: 143 sites / 32 files → 108 / 25, seven files out of the baseline entirely. Two real defects, both the shape the sweep exists to surface. Approvals' ADR-0043 action-link pages and sharing's public share-link REST routes each read the HTTP server under `http-server` only — the deprecated alias. The ledger records `http.server` as canonical and as the only name present on every provider path (`runtime.ts`'s `config.server` path registers no alias). On that path both lookups threw, the surrounding catch swallowed it, and the routes silently never mounted: approval e-mail action links 404'd and the share-link surface was absent, with nothing in the log to say so. Both reads are now canonical-first with the alias as fallback, each name in its own try — `getService` throws on an empty slot, so `a() ?? b()` inside one try never reaches `b` (the correction #4393 made in metadata and cloud-connection). Typing follows the B2/B3 method. Pure data-plane consumers take the narrow contract (`IDataEngine` in reports, whose `ReportEngine` is find/insert/ update/delete only); consumers that bind hook or middleware seams take the engine seen whole (`IObjectQLEngine` in approvals, sharing, pinyin-search), which is what the `objectql` slot's ledger entry describes. Slots with no contract get a named local surface, never `any`: plugin-email's new `MailSettingsSurface`, plus the surfaces the consuming packages already declared — `ApprovalMessagingSurface`, `SharingSecurityProbe`, `ReportEmail`. `hierarchy-scope-resolver` turned out to have a real contract already (`IHierarchyScopeResolver`), so it takes that. pinyin-search reads `IObjectQLEngine` through the `@objectstack/core` barrel, which re-exports it — the slot's contract is not a reason to add a spec dependency to a package that has none. Out of scope and deliberately left: `plugin-approvals/src/status-mirror- cascade.integration.test.ts` (3 sites) is a test file and belongs to B11. No contract changes. B6-B11 remain, plus the cloud-side alias readers and #4411 — this does not close #4251. Part of #4251 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PymBLAXECPWmZz3ZPcADR7 --- .changeset/slot-lookup-sweep-b5-plugins.md | 40 +++++++++++ .../plugin-approvals/src/approvals-plugin.ts | 47 +++++++++--- .../plugins/plugin-audit/src/audit-plugin.ts | 4 +- .../plugins/plugin-email/src/email-plugin.ts | 47 +++++++++++- .../src/pinyin-search-plugin.ts | 17 +++-- .../plugin-reports/src/reports-plugin.ts | 34 ++++++--- .../plugin-sharing/src/sharing-plugin.ts | 72 ++++++++++++++----- .../src/webhook-outbox-plugin.ts | 13 ++-- scripts/slot-lookup-baseline.json | 7 -- 9 files changed, 224 insertions(+), 57 deletions(-) create mode 100644 .changeset/slot-lookup-sweep-b5-plugins.md diff --git a/.changeset/slot-lookup-sweep-b5-plugins.md b/.changeset/slot-lookup-sweep-b5-plugins.md new file mode 100644 index 0000000000..ec4e0b7c37 --- /dev/null +++ b/.changeset/slot-lookup-sweep-b5-plugins.md @@ -0,0 +1,40 @@ +--- +"@objectstack/plugin-approvals": patch +"@objectstack/plugin-sharing": patch +"@objectstack/plugin-reports": patch +"@objectstack/plugin-email": patch +"@objectstack/plugin-pinyin-search": patch +"@objectstack/plugin-webhooks": patch +"@objectstack/plugin-audit": patch +--- + +fix(plugins): sweep the service-lookup erasures out of the plugin composition roots, and fix the two alias-only HTTP reads it exposed (#4251 B5) + +Batch B5 of the #4251 sweep: the seven remaining `packages/plugins/*` composition +roots. 35 lookup sites that had been erased to `any` now carry the slot's +contract, so the compiler checks what each plugin actually calls on the service +it resolved. The ratchet drops 143 sites / 32 files to 108 / 25. + +**Two real defects, both of the shape this sweep exists to find.** Approvals' +actionable-link pages (ADR-0043) and sharing's public share-link REST routes each +read the HTTP server under `http-server` *only* — the deprecated alias. The +ledger records `http.server` as canonical and as the only name present on every +provider path: `runtime.ts`'s `config.server` path registers no alias at all. On +that path both lookups threw, the surrounding `catch` swallowed it, and the +routes silently never mounted — approval e-mail action links 404'd and the +share-link surface was absent, with nothing in the log to say so. Both reads are +now canonical-first with the alias as fallback, each name in its own `try` +because `getService` throws on an empty slot (so `a() ?? b()` inside one `try` +never reaches `b` — the same correction #4393 made in metadata and +cloud-connection). + +Typing choices follow the batch method: pure data-plane consumers take the +narrow contract (`IDataEngine` in reports), consumers that bind hook or +middleware seams take the engine seen whole (`IObjectQLEngine` in approvals, +sharing and pinyin-search), and slots with no contract get a **named** local +surface rather than `any` — plugin-email's `MailSettingsSurface`, and the +surfaces the consuming packages already declared (`ApprovalMessagingSurface`, +`SharingSecurityProbe`, `ReportEmail`). A named surface that omits a member +still makes the compiler name every call site; `any` says nothing. + +No behaviour change beyond the two alias reads. No contract changes. diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index def48c277f..72c0f1c0e0 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -1,6 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import type { + IHttpServer, + II18nService, + IJobService, + IObjectQLEngine, +} from '@objectstack/spec/contracts'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; @@ -12,6 +18,7 @@ import { ESCALATION_JOB_NAME, ESCALATION_SCAN_INTERVAL_MS, type ApprovalEngine, + type ApprovalMessagingSurface, } from './approval-service.js'; import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js'; import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js'; @@ -55,7 +62,7 @@ export class ApprovalsServicePlugin implements Plugin { private readonly options: ApprovalsPluginOptions; private service?: ApprovalService; - private engine?: any; + private engine?: IObjectQLEngine; private escalationJobScheduled = false; constructor(options: ApprovalsPluginOptions = {}) { @@ -93,7 +100,7 @@ export class ApprovalsServicePlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { try { - const i18n = ctx.getService('i18n'); + const i18n = ctx.getService('i18n'); if (i18n && typeof i18n.loadTranslations === 'function') { const { ApprovalsTranslations } = await import('./translations/index.js'); for (const [locale, data] of Object.entries(ApprovalsTranslations)) { @@ -108,9 +115,14 @@ export class ApprovalsServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { if (this.options.disableService) return; - let engine: any = null; - try { engine = ctx.getService('objectql'); } - catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } + // This plugin needs the engine SEEN WHOLE, not the data plane: it binds + // `registerHook` / `unregisterHooksByPackage` below. That is `objectql`, + // whose ledger entry (`CoreServiceContracts`) records it as "the SAME + // instance as `data`, seen whole" — so the alias fallback resolves the same + // object and is typed as the same contract. + let engine: IObjectQLEngine | null = null; + try { engine = ctx.getService('objectql'); } + catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } if (!engine) { ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered'); return; @@ -158,7 +170,11 @@ export class ApprovalsServicePlugin implements Plugin { // remind / request-info / comment) notify users when present; without it // they degrade to audit-only. try { - const messaging = ctx.getService('messaging'); + // `messaging` has no contract in the ledger, so this is the named local + // surface the service itself already declares — not `any`. It omits + // members on purpose; omitting one it USES would be a compile error at + // the `attachMessaging` call, which is the whole point. + const messaging = ctx.getService('messaging'); if (messaging && typeof messaging.emit === 'function') { this.service.attachMessaging(messaging); } @@ -171,7 +187,7 @@ export class ApprovalsServicePlugin implements Plugin { // service → SLA stays display-only. const wireEscalationClock = async () => { try { - const jobs = ctx.getService('job'); + const jobs = ctx.getService('job'); if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return; const svc = this.service; const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS; @@ -213,7 +229,20 @@ export class ApprovalsServicePlugin implements Plugin { // happens exclusively on the POST (mail-gateway prefetch safe). const mountActionPages = async () => { try { - const http = ctx.getService('http-server'); + // [#4251 B5] Canonical name FIRST. This read was `http-server`-only, + // and `http-server` is the deprecated alias: the ledger records + // `http.server` as canonical and as "the ONLY name present on all + // provider paths" — `runtime.ts`'s `config.server` path registers no + // alias at all. On that path this lookup threw, the catch below + // swallowed it, and the ADR-0043 action pages silently never mounted, + // so every approval e-mail link 404'd. Same latent alias-only miss + // #4393 fixed in metadata/cloud-connection; per-name `try` because + // `getService` THROWS on an empty slot, so `a() ?? b()` in one `try` + // never reaches `b`. + const readServer = (name: string): IHttpServer | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + const http = readServer('http.server') ?? readServer('http-server'); const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null; if (!rawApp || !this.service) return; const svc = this.service; @@ -304,7 +333,7 @@ export class ApprovalsServicePlugin implements Plugin { async stop(ctx: PluginContext): Promise { if (this.escalationJobScheduled) { try { - const jobs = ctx.getService('job'); + const jobs = ctx.getService('job'); await jobs?.cancel?.(ESCALATION_JOB_NAME); } catch { /* ignore */ } this.escalationJobScheduled = false; diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index c0cae440a0..4b031bde06 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -2,7 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveLocalizationContext } from '@objectstack/core'; -import type { IDataEngine, ISharingService } from '@objectstack/spec/contracts'; +import type { IDataEngine, II18nService, ISharingService } from '@objectstack/spec/contracts'; import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; // `sys_notification` was parked here "until that [ADR-0030] migration lands". // It has landed, so the contribution moved to @objectstack/service-messaging — @@ -61,7 +61,7 @@ export class AuditPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { try { - const i18n = ctx.getService('i18n'); + const i18n = ctx.getService('i18n'); if (i18n && typeof i18n.loadTranslations === 'function') { const { AuditTranslations } = await import('./translations/index.js'); for (const [locale, data] of Object.entries(AuditTranslations)) { diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 4f5528c178..4e8f619846 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -28,7 +28,11 @@ import { type EmailTransportProvider, } from './transports/index.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; -import type { EmailTemplateDefinition as EmailTemplate } from '@objectstack/spec/system'; +import type { + EmailTemplateDefinition as EmailTemplate, + SettingsChangeHandler, + SettingsUnsubscribe, +} from '@objectstack/spec/system'; import { bootstrapDeclaredEmailTemplates, upsertDeclaredEmailTemplate, @@ -120,6 +124,43 @@ const QUEUE_DELIVERY_BACKOFF: QueueBackoffPolicy = { maxDelayMs: 5 * 60_000, }; +/** + * The `settings` slot as THIS plugin consumes it. + * + * [#4251 B5] `service-settings` registers its `SettingsService` here and the + * slot carries no `packages/spec` contract, so the four members this plugin + * calls are declared structurally — plugin-email must not take a runtime + * dependency on service-settings, which is optional. Same shape and reasoning + * as plugin-auth's and rest's `SettingsReadSurface`; each consumer names the + * slice it uses, so omitting a member it calls is a compile error rather than + * silence. The change-bus types are the SPEC's, not re-declared here. + */ +interface MailSettingsSurface { + /** + * Capability probe only — never called. Its presence is what the call site + * reads as "this settings service can serve a live client", so it is typed + * as a member of unknown shape rather than given a fictional signature. + */ + createClient?: unknown; + /** Resolve the whole `mail` namespace as `key → { value, source }`. */ + getNamespace( + namespace: string, + ctx?: Record, + ): Promise<{ values: Record }>; + /** Rebuild the transport when the namespace changes. Optional: no change bus → the boot read stands. */ + subscribe?(namespace: string | undefined, handler: SettingsChangeHandler): SettingsUnsubscribe; + /** Override service-settings' validate-only `mail/test` fallback with a real send. */ + registerAction?( + namespace: string, + action: string, + handler: (input: { + values?: Record; + payload?: Record; + ctx?: { body?: Record }; + }) => Promise, + ): void; +} + /** * Resolve a queue service that can actually carry a durable email job, or * `undefined`. @@ -302,7 +343,7 @@ export class EmailServicePlugin implements Plugin { // without restarting the process. Env-locked fields still win at // the resolver level, so config-via-env keeps its precedence. try { - const settings = ctx.getService('settings'); + const settings = ctx.getService('settings'); if (settings && typeof settings.createClient === 'function') { const applySettings = async (phase: 'boot' | 'saved' = 'boot') => { try { @@ -625,7 +666,7 @@ export class EmailServicePlugin implements Plugin { // the true attempt count. One message is one row; `attempt_count` // accumulates on it across redeliveries. try { - const queue: any = ctx.getService('queue'); + const queue = ctx.getService('queue'); if (queue && typeof queue.subscribe === 'function' && this.service) { const svc = this.service; await queue.subscribe(EMAIL_SEND_QUEUE, async (msg: any) => { diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts index ed183b908f..d19550b56d 100644 --- a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts +++ b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts @@ -18,7 +18,10 @@ * `pinyin-pro` is never imported. */ -import type { Plugin, PluginContext } from '@objectstack/core'; +// `IObjectQLEngine` via the core barrel, which re-exports it from +// `@objectstack/spec/contracts`: this package does not depend on spec directly, +// and the slot's contract is not a reason to add a dependency. +import type { IObjectQLEngine, Plugin, PluginContext } from '@objectstack/core'; import { resolveSearchPinyinEnabled } from '@objectstack/types'; import { bindSearchCompanionHooks, @@ -91,12 +94,18 @@ export class PinyinSearchPlugin implements Plugin { } } - private resolveEngine(ctx: PluginContext): any { + /** + * [#4251 B5] The engine SEEN WHOLE — `bindSearchCompanionHooks` binds + * `registerHook`, which lives on `IObjectQLEngine`, not on the data plane. + * The ledger records `objectql` as "the SAME instance as `data`, seen whole", + * so the alias fallback resolves the same object under the same contract. + */ + private resolveEngine(ctx: PluginContext): IObjectQLEngine | null { try { - return ctx.getService('objectql'); + return ctx.getService('objectql'); } catch { try { - return ctx.getService('data'); + return ctx.getService('data'); } catch { return null; } diff --git a/packages/plugins/plugin-reports/src/reports-plugin.ts b/packages/plugins/plugin-reports/src/reports-plugin.ts index e58e8f75be..c38127db0c 100644 --- a/packages/plugins/plugin-reports/src/reports-plugin.ts +++ b/packages/plugins/plugin-reports/src/reports-plugin.ts @@ -1,6 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import type { + IDataEngine, + IJobService, + ISecurityService, + SecurityContext, +} from '@objectstack/spec/contracts'; import { SysSavedReport, SysReportSchedule, @@ -46,7 +52,7 @@ export class ReportsServicePlugin implements Plugin { private service?: ReportService; private intervalHandle?: ReturnType; private jobName?: string; - private jobService?: any; + private jobService?: IJobService; constructor(options: ReportsPluginOptions = {}) { this.options = options; @@ -68,16 +74,23 @@ export class ReportsServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { ctx.hook('kernel:ready', async () => { - let engine: any = null; - try { engine = ctx.getService('objectql'); } - catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } + // `IDataEngine`, not the whole engine: `ReportEngine` is a pure data-plane + // slice (find/findOne/insert/update/delete), so the narrow contract is the + // honest one for BOTH names — `objectql` strictly widens `data` (#4404), + // and nothing here reaches past the data plane. + let engine: IDataEngine | null = null; + try { engine = ctx.getService('objectql'); } + catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } if (!engine) { ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered'); return; } let email: ReportEmail | undefined; - try { email = ctx.getService('email'); } catch { /* email is optional */ } + // `ReportEmail` — the named surface this plugin consumes. `IEmailService` + // passes straight through it (see the declaration); naming the slice is + // what makes a drift in `send`'s shape a compile error here. + try { email = ctx.getService('email'); } catch { /* email is optional */ } if (!email) { ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery'); } @@ -92,10 +105,13 @@ export class ReportsServicePlugin implements Plugin { // permission sets anywhere) → the axis does not apply, matching the REST // export route's fail-open. const canExport = async (object: string, context: unknown): Promise => { - let security: any; - try { security = ctx.getService('security'); } catch { return true; } + let security: ISecurityService | undefined; + try { security = ctx.getService('security'); } catch { return true; } if (!security || typeof security.canExport !== 'function') return true; - return await security.canExport(object, context); + // `ReportService` hands this callback an `unknown` context (it is the + // caller's execution envelope, opaque to reports); the security service + // types it as a partial `ExecutionContext`. + return await security.canExport(object, context as SecurityContext | undefined); }; this.service = new ReportService({ @@ -124,7 +140,7 @@ export class ReportsServicePlugin implements Plugin { // Prefer the platform job service when available — it lets ops // see report dispatch alongside every other scheduled job. try { - const job = ctx.getService('job'); + const job = ctx.getService('job'); if (job && typeof job.schedule === 'function') { this.jobService = job; this.jobName = 'reports.dispatch'; diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index e970198434..7dd3c4633e 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -3,14 +3,28 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveAuthzContext } from '@objectstack/core'; import type { EngineMiddleware, OperationContext } from '@objectstack/objectql'; -import type { IHttpServer, IHttpRequest } from '@objectstack/spec/contracts'; +import type { + AuthSessionApi, + IAuthService, + IHierarchyScopeResolver, + IHttpRequest, + IHttpServer, + II18nService, + IMetadataService, + IObjectQLEngine, +} from '@objectstack/spec/contracts'; // [#6206] The share-link routes' context is the FULL authorization envelope — // it feeds enforcement (`engine.find`), so it is an `ExecutionContext`, never // the route-local `ShareLinkExecutionContext`. import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js'; import { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; -import { SharingService, type SharingEngine, type SharingTenancyProbe } from './sharing-service.js'; +import { + SharingService, + type SharingEngine, + type SharingSecurityProbe, + type SharingTenancyProbe, +} from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; @@ -412,7 +426,7 @@ export class SharingServicePlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { try { - const i18n = ctx.getService('i18n'); + const i18n = ctx.getService('i18n'); if (i18n && typeof i18n.loadTranslations === 'function') { const { SharingTranslations } = await import('./translations/index.js'); for (const [locale, data] of Object.entries(SharingTranslations)) { @@ -427,9 +441,13 @@ export class SharingServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { ctx.hook('kernel:ready', async () => { - let engine: any = null; - try { engine = ctx.getService('objectql'); } - catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } + // The engine SEEN WHOLE (`registerHook` / `unregisterHooksByPackage` / + // `registerMiddleware` are all bound below), which is the `objectql` + // slot. Its ledger entry records it as "the SAME instance as `data`, + // seen whole", so the alias fallback resolves the same object. + let engine: IObjectQLEngine | null = null; + try { engine = ctx.getService('objectql'); } + catch { try { engine = ctx.getService('data'); } catch { /* ignore */ } } if (!engine) { ctx.logger.warn('SharingServicePlugin: no ObjectQL engine — service NOT registered'); return; @@ -443,14 +461,17 @@ export class SharingServicePlugin implements Plugin { // [ADR-0057] Late-bound lookup of the enterprise hierarchy resolver. // Open edition: not registered → hierarchy scopes fail closed to own. hierarchyResolver: () => { - try { return ctx.getService('hierarchy-scope-resolver'); } + try { return ctx.getService('hierarchy-scope-resolver'); } catch { return null; } }, // [ADR-0111 D1/D2] Late-bound security probe for canManageShares' // Modify-All path. Absent (no plugin-security) → owner-only, fail // closed — a degraded security stack never widens sharing authority. securityService: () => { - try { return ctx.getService('security'); } + // The named surface this option already requires — plugin-security is + // OPTIONAL, so the consumer declares the slice it probes rather than + // taking a runtime dependency on `ISecurityService`. + try { return ctx.getService('security'); } catch { return null; } }, // [ADR-0105 D1 / #5859] Late-bound tenancy posture — read exactly the @@ -541,8 +562,8 @@ export class SharingServicePlugin implements Plugin { // sys_sharing_rule BEFORE listRules so the lifecycle hooks bind to a // populated table (previously rules were decorative — ruleCount: 0). try { - let metadataService: any = null; - try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + let metadataService: IMetadataService | null = null; + try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } if (metadataService) { await bootstrapDeclaredSharingRules(this.ruleService, metadataService, engine, ctx.logger as any); } @@ -604,12 +625,19 @@ export class SharingServicePlugin implements Plugin { ctx.registerService('shareLinks', this.linkService); if (this.options.registerShareLinkRoutes !== false) { - let http: IHttpServer | null = null; - try { - http = ctx.getService('http-server'); - } catch { - // No HTTP server — service still reachable via getService. - } + // [#4251 B5] Canonical name FIRST, alias second. This read was + // `http-server`-only, and that is the DEPRECATED alias: the ledger + // records `http.server` as canonical and as the only name present on + // every provider path (`runtime.ts`'s `config.server` path registers + // no alias). On that path the share-link REST routes silently never + // mounted. Per-name `try` because `getService` throws on an empty + // slot, so both names cannot share one `try` (#4393). + // Neither name present → no HTTP server; the service stays reachable + // via getService. + const readServer = (name: string): IHttpServer | null => { + try { return ctx.getService(name); } catch { return null; } + }; + const http: IHttpServer | null = readServer('http.server') ?? readServer('http-server'); if (http) { // [Finding-2] Derive the caller from the platform's VERIFIED // resolution (session / API key / OAuth), never from spoofable @@ -635,7 +663,7 @@ export class SharingServicePlugin implements Plugin { // grows a dimension nobody remembers to add here. The route's own // 401 decision reads `userId` off the same object (see // `ShareLinkExecutionContext` in the contract for that boundary). - const ql: any = engine; + const ql = engine; const verifiedContextFromRequest = async (req: IHttpRequest): Promise => { try { const headers = new Headers(); @@ -645,8 +673,14 @@ export class SharingServicePlugin implements Plugin { } const getSession = async (h: any) => { try { - const authService: any = ctx.getService('auth'); - let api: any = authService?.api; + // Both members are OPTIONAL on the contract, and that is + // load-bearing: the shipped plugin-auth registers an + // `AuthManager`, which has no `api` member at all (#4127 + // batch 4), so on every real stack this falls through to + // `getApi()`. Typed, the optionality is visible; erased, the + // dead first branch looked like the primary path. + const authService = ctx.getService('auth'); + let api: AuthSessionApi | undefined = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); return await api?.getSession?.({ headers: h }); } catch { diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 929aba428d..8f117f3c57 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -1,7 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts'; +import type { + IDataEngine, + II18nService, + IMetadataService, + IRealtimeService, +} from '@objectstack/spec/contracts'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; import { SysWebhook } from './sys-webhook.object.js'; @@ -116,7 +121,7 @@ export class WebhookOutboxPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { try { - const i18n = ctx.getService('i18n'); + const i18n = ctx.getService('i18n'); if (i18n && typeof i18n.loadTranslations === 'function') { const { WebhooksTranslations } = await import('./translations/index.js'); for (const [locale, data] of Object.entries(WebhooksTranslations)) { @@ -181,8 +186,8 @@ export class WebhookOutboxPlugin implements Plugin { // Bind the provenance stamp so an admin edit freezes a seeded row. this.boundEngine = engine; bindWebhookProvenanceStamp(engine as any, ctx.logger as any); - let metadataService: any; - try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + let metadataService: IMetadataService | undefined; + try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } try { await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any); } catch (err: any) { diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 2b4c1409d8..54543fbdf2 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -7,14 +7,7 @@ "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 16, "packages/core/examples/kernel-features-example.ts": 5, "packages/objectql/src/plugin.integration.test.ts": 23, - "packages/plugins/plugin-approvals/src/approvals-plugin.ts": 9, "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 3, - "packages/plugins/plugin-audit/src/audit-plugin.ts": 1, - "packages/plugins/plugin-email/src/email-plugin.ts": 3, - "packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts": 2, - "packages/plugins/plugin-reports/src/reports-plugin.ts": 8, - "packages/plugins/plugin-sharing/src/sharing-plugin.ts": 10, - "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts": 2, "packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts": 1,