diff --git a/src/__testing__/mesheryExtensionContract.test.ts b/src/__testing__/mesheryExtensionContract.test.ts new file mode 100644 index 000000000..c715d8aa6 --- /dev/null +++ b/src/__testing__/mesheryExtensionContract.test.ts @@ -0,0 +1,288 @@ +import { + MESHERY_EXTENSION_CAPABILITIES, + MESHERY_EXTENSION_CONTRACT_VERSION, + MESHERY_EXTENSION_DEPRECATED_CAPABILITIES, + MESHERY_EXTENSION_EVENT, + MESHERY_EXTENSION_EVENT_TYPES, + MESHERY_EXTENSION_HOOKS, + describeInjectedCapabilityReport, + isInjectedCapabilityReportSatisfied, + isMesheryExtensionEvent, + reportInjectedCapabilities +} from '../actors/mesheryExtensionContract'; + +/** A host bag that satisfies the contract exactly, built from the contract itself. */ +const satisfyingInjectProps = () => ({ + ...Object.fromEntries(MESHERY_EXTENSION_CAPABILITIES.map(key => [key, () => null])), + hooks: Object.fromEntries(MESHERY_EXTENSION_HOOKS.map(hook => [hook, () => null])) +}); + +describe('Meshery extension contract — event literals', () => { + it('exposes a literal for every event type on the bus', () => { + // The union -> map completeness guard is compile-time; this asserts the + // runtime array stays in step with the map it is derived from. + expect([...MESHERY_EXTENSION_EVENT_TYPES].sort()).toEqual( + Object.values(MESHERY_EXTENSION_EVENT).sort() + ); + }); + + it('pins the wire literals that cross the host boundary', () => { + // Hard-coded on purpose. These strings are the actual contract: a rename is + // only safe alongside a contract-version bump and a deprecation entry, and + // this test is what forces that conversation. Renaming + // OPEN_DESIGN_IN_KANVAS -> OPEN_DESIGN_IN_EXTENSION silently broke opening a + // design because nothing pinned them. + expect(MESHERY_EXTENSION_EVENT).toEqual({ + K8sContextsUpdated: 'K8S_CONTEXTS_UPDATED', + OpenViewScopedToDesign: 'OPEN_VIEW_SCOPED_TO_DESIGN', + OpenDesignInExtension: 'OPEN_DESIGN_IN_EXTENSION', + OpenViewInExtension: 'OPEN_VIEW_IN_EXTENSION', + MergeDesign: 'MERGE_DESIGN', + DispatchToMesheryStore: 'DISPATCH_TO_MESHERY_STORE', + FeatureRequiresUserAccount: 'FeatureRequiresUserAccount', + MissingPermission: 'MISSING_PERMISSION', + MissingCapability: 'MISSING_CAPABILITY' + }); + }); + + it('recognises contract events and rejects unknown literals', () => { + expect( + isMesheryExtensionEvent({ + type: MESHERY_EXTENSION_EVENT.OpenDesignInExtension, + data: { designId: 'd', designName: 'n' } + }) + ).toBe(true); + // The pre-fix literal. A subscriber that receives this is talking to a host + // built against a different contract and must say so rather than no-op. + expect(isMesheryExtensionEvent({ type: 'OPEN_DESIGN_IN_KANVAS', data: {} })).toBe(false); + }); + + it('rejects non-event values without throwing', () => { + expect(isMesheryExtensionEvent(null)).toBe(false); + expect(isMesheryExtensionEvent(undefined)).toBe(false); + expect(isMesheryExtensionEvent('OPEN_DESIGN_IN_EXTENSION')).toBe(false); + expect(isMesheryExtensionEvent({})).toBe(false); + }); + + it('rejects a known literal carrying a payload this build cannot read', () => { + // The rename half of the same skew: the host keeps publishing a literal we + // recognise but renames the field inside it, so every subscriber reads + // undefined. Narrowing on the discriminant alone would call this valid and + // hand the subscriber a payload that throws on first property access. + const type = MESHERY_EXTENSION_EVENT.OpenDesignInExtension; + + expect(isMesheryExtensionEvent({ type, data: null })).toBe(false); + expect(isMesheryExtensionEvent({ type })).toBe(false); + expect(isMesheryExtensionEvent({ type, data: {} })).toBe(false); + // `designId` renamed to `id` - recognised literal, unreadable payload. + expect(isMesheryExtensionEvent({ type, data: { id: 'd', designName: 'n' } })).toBe(false); + expect(isMesheryExtensionEvent({ type, data: { designId: 1, designName: 'n' } })).toBe(false); + }); + + it('accepts a valid payload for every event literal', () => { + const valid: Record<(typeof MESHERY_EXTENSION_EVENT_TYPES)[number], unknown> = { + K8S_CONTEXTS_UPDATED: { selectedK8sContexts: ['ctx'] }, + OPEN_VIEW_SCOPED_TO_DESIGN: { designId: 'd', designName: 'n' }, + OPEN_DESIGN_IN_EXTENSION: { designId: 'd', designName: 'n' }, + OPEN_VIEW_IN_EXTENSION: { viewId: 'v', viewName: 'n' }, + MERGE_DESIGN: { id: 'i', name: 'n' }, + DISPATCH_TO_MESHERY_STORE: { type: 'redux/action' }, + FeatureRequiresUserAccount: { feature: 'f' }, + MISSING_PERMISSION: { keyId: 'k' }, + MISSING_CAPABILITY: { capabilityId: 'c' } + }; + + for (const type of MESHERY_EXTENSION_EVENT_TYPES) { + expect(isMesheryExtensionEvent({ type, data: valid[type] })).toBe(true); + } + }); + + it('tolerates extra payload fields so additive changes stay compatible', () => { + // Only a rename or removal is a contract break. A newer host adding a field + // to a payload must not make an older bundle reject the whole event. + expect( + isMesheryExtensionEvent({ + type: MESHERY_EXTENSION_EVENT.MergeDesign, + data: { id: 'i', name: 'n', addedByANewerHost: true } + }) + ).toBe(true); + expect( + isMesheryExtensionEvent({ + type: MESHERY_EXTENSION_EVENT.DispatchToMesheryStore, + data: { type: 'redux/action', payload: { a: 1 } } + }) + ).toBe(true); + }); + + it('does not mistake an inherited Object member for a payload check', () => { + // The check table is a plain object, so an event whose `type` names something + // on Object.prototype must not resolve to an inherited function and pass. + expect(isMesheryExtensionEvent({ type: 'toString', data: {} })).toBe(false); + expect(isMesheryExtensionEvent({ type: 'constructor', data: {} })).toBe(false); + expect(isMesheryExtensionEvent({ type: 'hasOwnProperty', data: {} })).toBe(false); + }); +}); + +describe('Meshery extension contract — injected capabilities', () => { + it('reports a fully-satisfying host as satisfied', () => { + const report = reportInjectedCapabilities(satisfyingInjectProps()); + + expect(report.missing).toEqual([]); + expect(report.missingHooks).toEqual([]); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(true); + expect(describeInjectedCapabilityReport(report)).toBeNull(); + }); + + it('names the capability a host stopped injecting', () => { + // This is the capabilitiesRegistry -> providerCapabilities incident in + // miniature: the host drops a key, every read yields undefined, and the + // failure used to surface as an unrelated crash far from the cause. + const injectProps = satisfyingInjectProps(); + delete (injectProps as Record).providerCapabilities; + + const report = reportInjectedCapabilities(injectProps); + + expect(report.missing).toEqual(['providerCapabilities']); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(false); + expect(describeInjectedCapabilityReport(report)).toContain('providerCapabilities'); + }); + + it('names a missing nested hook', () => { + const injectProps = { ...satisfyingInjectProps(), hooks: { CAN: () => true } }; + + const report = reportInjectedCapabilities(injectProps); + + expect(report.missingHooks).toEqual(['useDynamicComponent', 'useFilterK8sContexts']); + expect(describeInjectedCapabilityReport(report)).toContain('useFilterK8sContexts'); + }); + + it('does not double-report hooks when the whole hooks bag is absent', () => { + const injectProps = satisfyingInjectProps(); + delete (injectProps as Record).hooks; + + const report = reportInjectedCapabilities(injectProps); + + expect(report.missing).toContain('hooks'); + expect(report.missingHooks).toEqual([]); + }); + + it('flags deprecated aliases alongside their replacement without failing the check', () => { + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + capabilitiesRegistry: {}, + CapabilitiesRegistryClass: class {} + }); + + expect(report.deprecated).toEqual([ + { provided: 'capabilitiesRegistry', replacement: 'providerCapabilities' }, + { provided: 'CapabilitiesRegistryClass', replacement: 'ProviderUiAccessControlClass' } + ]); + // Legacy bundles still depend on these, so their presence is informational. + expect(isInjectedCapabilityReportSatisfied(report)).toBe(true); + }); + + it('does not report the host handshake field as an unknown key', () => { + // The host advertises `contractVersion` on the same bag. Reporting the + // host's own handshake field as unrecognized is noise, and noise is what + // trains people to stop reading the report. + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + contractVersion: MESHERY_EXTENSION_CONTRACT_VERSION + }); + + expect(report.unrecognized).toEqual([]); + }); + + it('reports keys this build has never heard of as unrecognized', () => { + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + someCapabilityFromANewerHost: () => null + }); + + expect(report.unrecognized).toEqual(['someCapabilityFromANewerHost']); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(true); + }); + + it('treats a null or undefined bag as every capability missing', () => { + for (const bag of [null, undefined]) { + const report = reportInjectedCapabilities(bag); + expect(report.missing).toEqual([...MESHERY_EXTENSION_CAPABILITIES]); + expect(describeInjectedCapabilityReport(report)).toContain('contract'); + } + }); + + it('keeps every deprecated alias pointing at a real capability or an outright removal', () => { + for (const replacement of Object.values(MESHERY_EXTENSION_DEPRECATED_CAPABILITIES)) { + if (replacement === null) continue; + expect(MESHERY_EXTENSION_CAPABILITIES).toContain(replacement); + } + }); + + it('never lists a deprecated alias as a current capability', () => { + for (const alias of Object.keys(MESHERY_EXTENSION_DEPRECATED_CAPABILITIES)) { + expect(MESHERY_EXTENSION_CAPABILITIES).not.toContain(alias); + } + }); +}); + +describe('Meshery extension contract — version', () => { + it('is a positive integer hosts and extensions can compare', () => { + expect(Number.isInteger(MESHERY_EXTENSION_CONTRACT_VERSION)).toBe(true); + expect(MESHERY_EXTENSION_CONTRACT_VERSION).toBeGreaterThan(0); + }); + + it('treats a matching advertised version as compatible', () => { + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + contractVersion: MESHERY_EXTENSION_CONTRACT_VERSION + }); + + expect(report.contractVersionMismatch).toBeNull(); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(true); + }); + + it('treats a host that advertises no version as the supported legacy case', () => { + // Every host deployed before this handshake existed. They predate the field + // rather than disagreeing about it, so flagging them would make the report + // fire everywhere at once and teach maintainers to ignore it. + const report = reportInjectedCapabilities(satisfyingInjectProps()); + + expect(report.contractVersionMismatch).toBeNull(); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(true); + expect(describeInjectedCapabilityReport(report)).toBeNull(); + }); + + it('fails the handshake loudly when the advertised version differs', () => { + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + contractVersion: MESHERY_EXTENSION_CONTRACT_VERSION + 1 + }); + + expect(report.contractVersionMismatch).toEqual({ + host: MESHERY_EXTENSION_CONTRACT_VERSION + 1, + extension: MESHERY_EXTENSION_CONTRACT_VERSION + }); + // Every capability is present, so nothing else in the report would catch this. + expect(report.missing).toEqual([]); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(false); + + const message = describeInjectedCapabilityReport(report); + expect(message).toContain(`v${MESHERY_EXTENSION_CONTRACT_VERSION + 1}`); + expect(message).toContain(`v${MESHERY_EXTENSION_CONTRACT_VERSION}`); + }); + + it('treats a non-numeric advertised version as a mismatch rather than trusting it', () => { + // `injectProps` is untrusted input; a version we cannot compare is not a + // version we may assume matches. + const report = reportInjectedCapabilities({ + ...satisfyingInjectProps(), + contractVersion: `${MESHERY_EXTENSION_CONTRACT_VERSION}` + }); + + expect(report.contractVersionMismatch).toEqual({ + host: `${MESHERY_EXTENSION_CONTRACT_VERSION}`, + extension: MESHERY_EXTENSION_CONTRACT_VERSION + }); + expect(isInjectedCapabilityReportSatisfied(report)).toBe(false); + }); +}); diff --git a/src/actors/index.ts b/src/actors/index.ts index a366767db..f65121db6 100644 --- a/src/actors/index.ts +++ b/src/actors/index.ts @@ -39,3 +39,5 @@ export { } from './utils'; export * from './eventBus'; + +export * from './mesheryExtensionContract'; diff --git a/src/actors/mesheryExtensionContract.ts b/src/actors/mesheryExtensionContract.ts new file mode 100644 index 000000000..70841afac --- /dev/null +++ b/src/actors/mesheryExtensionContract.ts @@ -0,0 +1,404 @@ +/** + * The Meshery host <-> UI extension contract. + * + * Meshery UI ("the host") loads extension bundles (Kanvas et al.) at runtime with + * `@paciolan/remote-component`. Three things cross that boundary: + * + * 1. the bundle's export shape (the host reads `module.exports.default`), + * 2. a bag of injected capabilities the host hands the bundle as `injectProps`, + * 3. an RxJS event bus both sides publish on. + * + * (2) and (3) used to be hand-duplicated string literals in each repo, with the + * host bus declared as a bare `new EventBus()` — whose type parameter collapses to + * its constraint, so `publish()` accepted any `{ type: string }`. A rename on + * either side therefore compiled cleanly on both and failed silently at runtime: + * the subscriber's `if (event.type === ...)` simply stopped matching and the + * feature became a no-op with no error, no warning, and no failing test. That is + * how `OPEN_DESIGN_IN_KANVAS` -> `OPEN_DESIGN_IN_EXTENSION` shipped broken, and + * how the `capabilitiesRegistry` -> `providerCapabilities` prop rename shipped + * before it. + * + * This module is the single declaration both sides derive from. Typing the host + * bus as `EventBus` and deriving every literal from + * `MESHERY_EXTENSION_EVENT` turns such a rename into a compile error in every + * consumer instead of a silent runtime no-op. + * + * Editing anything in this file is a contract change: + * - additive (new event / new capability) -> no version bump needed, + * - rename or removal -> bump `MESHERY_EXTENSION_CONTRACT_VERSION` and add the + * old name to the deprecated table so already-published bundles keep working. + * + * Extension bundles are published artifacts loaded into whichever host version is + * deployed, so host and extension are never guaranteed to be built from the same + * commit. Compile-time agreement is necessary but not sufficient — the runtime + * handshake helpers below exist to make that skew visible rather than silent. + */ +import type { EventBus } from './eventBus'; + +/** + * Incremented only when an event or capability is renamed or removed. The host + * injects this as `contractVersion`; extensions compare it against the value they + * were built against and surface a loud, actionable mismatch instead of failing + * feature-by-feature at runtime. + */ +export const MESHERY_EXTENSION_CONTRACT_VERSION = 1; + +/* -------------------------------------------------------------------------- */ +/* Events: host -> extension */ +/* -------------------------------------------------------------------------- */ + +export type K8sContextsUpdatedEvent = { + type: 'K8S_CONTEXTS_UPDATED'; + data: { selectedK8sContexts: string[] }; +}; + +export type OpenViewScopedToDesignEvent = { + type: 'OPEN_VIEW_SCOPED_TO_DESIGN'; + data: { designId: string; designName: string }; +}; + +export type OpenDesignInExtensionEvent = { + type: 'OPEN_DESIGN_IN_EXTENSION'; + data: { designId: string; designName: string }; +}; + +export type OpenViewInExtensionEvent = { + type: 'OPEN_VIEW_IN_EXTENSION'; + data: { viewId: string; viewName: string }; +}; + +export type MergeDesignEvent = { + type: 'MERGE_DESIGN'; + data: { id: string; name: string }; +}; + +/* -------------------------------------------------------------------------- */ +/* Events: extension -> host */ +/* -------------------------------------------------------------------------- */ + +export type DispatchToMesheryStoreEvent = { + type: 'DISPATCH_TO_MESHERY_STORE'; + data: { type: string; payload?: Record }; +}; + +export type FeatureRequiresUserAccountEvent = { + type: 'FeatureRequiresUserAccount'; + data: { feature: string }; +}; + +/* -------------------------------------------------------------------------- */ +/* Events: published by sistent's PermissionShield onto whichever bus it is */ +/* handed, which in Meshery is the host <-> extension bus. */ +/* -------------------------------------------------------------------------- */ + +export type MissingPermissionReason = { + type: 'MISSING_PERMISSION'; + data: { keyId: string }; +}; + +export type MissingCapabilityReason = { + type: 'MISSING_CAPABILITY'; + data: { capabilityId: string }; +}; + +/** Every event that may legitimately appear on the host <-> extension bus. */ +export type MesheryExtensionEvent = + | K8sContextsUpdatedEvent + | OpenViewScopedToDesignEvent + | OpenDesignInExtensionEvent + | OpenViewInExtensionEvent + | MergeDesignEvent + | DispatchToMesheryStoreEvent + | FeatureRequiresUserAccountEvent + | MissingPermissionReason + | MissingCapabilityReason; + +/** + * The bus type both the host and every extension must use. Declaring the host bus + * as `new EventBus()` (no type argument) silently widens `T` to its constraint and + * disables all publish-site checking — always use this alias. + */ +export type MesheryExtensionEventBus = EventBus; + +/** + * Named handles for every event literal. Publish and subscribe sites reference + * these rather than inline strings so a rename here breaks compilation at every + * call site in every repo. + */ +export const MESHERY_EXTENSION_EVENT = { + K8sContextsUpdated: 'K8S_CONTEXTS_UPDATED', + OpenViewScopedToDesign: 'OPEN_VIEW_SCOPED_TO_DESIGN', + OpenDesignInExtension: 'OPEN_DESIGN_IN_EXTENSION', + OpenViewInExtension: 'OPEN_VIEW_IN_EXTENSION', + MergeDesign: 'MERGE_DESIGN', + DispatchToMesheryStore: 'DISPATCH_TO_MESHERY_STORE', + FeatureRequiresUserAccount: 'FeatureRequiresUserAccount', + MissingPermission: 'MISSING_PERMISSION', + MissingCapability: 'MISSING_CAPABILITY' +} as const satisfies Record; + +export type MesheryExtensionEventType = MesheryExtensionEvent['type']; + +/** + * Compile-time completeness guard. Adding a member to `MesheryExtensionEvent` + * without adding it to `MESHERY_EXTENSION_EVENT` makes this assignment fail, so + * the map can never drift behind the union it describes. + */ +type UnmappedEventType = Exclude< + MesheryExtensionEventType, + (typeof MESHERY_EXTENSION_EVENT)[keyof typeof MESHERY_EXTENSION_EVENT] +>; +const _everyEventIsMapped: UnmappedEventType extends never ? true : never = true; +void _everyEventIsMapped; + +/** Every event literal, for runtime membership checks. */ +export const MESHERY_EXTENSION_EVENT_TYPES = Object.values( + MESHERY_EXTENSION_EVENT +) as readonly MesheryExtensionEventType[]; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isString = (value: unknown): value is string => typeof value === 'string'; + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isString); + +/** + * Payload check per event literal, keyed by the literal itself. + * + * The type discriminant alone is not the contract - the payload shape is half of + * it. A host that renames `designId` to `id` keeps publishing a literal this + * build recognises while every subscriber reads `undefined`, which is the same + * silent no-op the literal map exists to prevent. Checking only required fields + * (never rejecting extra ones) keeps additive payload changes compatible while + * making a rename or removal visible. + * + * `satisfies Record` is the completeness guard: + * adding a member to `MesheryExtensionEvent` without a check here fails to + * compile, so this table cannot drift behind the union. + */ +const MESHERY_EXTENSION_EVENT_DATA_CHECKS = { + K8S_CONTEXTS_UPDATED: data => isStringArray(data.selectedK8sContexts), + OPEN_VIEW_SCOPED_TO_DESIGN: data => isString(data.designId) && isString(data.designName), + OPEN_DESIGN_IN_EXTENSION: data => isString(data.designId) && isString(data.designName), + OPEN_VIEW_IN_EXTENSION: data => isString(data.viewId) && isString(data.viewName), + MERGE_DESIGN: data => isString(data.id) && isString(data.name), + DISPATCH_TO_MESHERY_STORE: data => + isString(data.type) && (data.payload === undefined || isRecord(data.payload)), + FeatureRequiresUserAccount: data => isString(data.feature), + MISSING_PERMISSION: data => isString(data.keyId), + MISSING_CAPABILITY: data => isString(data.capabilityId) +} as const satisfies Record) => boolean>; + +/** + * Narrows an event received from the bus to a known contract member, checking + * both the type discriminant and the payload it promises to carry. + * + * Subscribers must treat a `false` result as a defect to report, not as something + * to ignore. It means the publisher is emitting either a literal this build has + * never heard of or a payload this build cannot read - both are the host/extension + * skew that used to present as a silently dead feature, and both call for the same + * response: say so loudly rather than no-op. + */ +export const isMesheryExtensionEvent = (event: unknown): event is MesheryExtensionEvent => { + if (!isRecord(event)) return false; + const type = event.type as MesheryExtensionEventType; + // Membership is checked against the literal array before indexing the check + // table, so a payload carrying `type: 'toString'` cannot reach an inherited + // Object.prototype member and be mistaken for a validator. + if (!MESHERY_EXTENSION_EVENT_TYPES.includes(type)) return false; + return isRecord(event.data) && MESHERY_EXTENSION_EVENT_DATA_CHECKS[type](event.data); +}; + +/* -------------------------------------------------------------------------- */ +/* Injected capabilities */ +/* -------------------------------------------------------------------------- */ + +/** + * Keys the host is expected to place on `injectProps`. Extensions read these off + * the injected bag; a key the host stops providing resolves to `undefined` at + * every use site, which historically surfaced as an unrelated React crash far + * from the actual cause. + */ +export const MESHERY_EXTENSION_CAPABILITIES = [ + 'CreateModelModal', + 'DeployStepper', + 'DryRunDesign', + 'ExportModal', + 'GenericRJSFModal', + 'ImportModelModal', + 'InfoModal', + 'MesheryPerformanceComponent', + 'PatternServiceFormCore', + 'ProviderUiAccessControlClass', + 'RJSForm', + 'RelationshipEvaluationResponseFormatter', + 'SetCurrentLoadedResourceInOrgWorkspaceSession', + 'StructuredDataFormatter', + 'ThemeTogglerCore', + 'TypingFilter', + 'UnDeployStepper', + 'ValidateDesign', + 'ViewInfoModal', + '_PromptComponent', + 'currentOrganization', + 'designValidationMachine', + 'hooks', + 'mesheryEventBus', + 'mesheryStore', + 'openRegistryModal', + 'openWorkspaceModal', + 'providerCapabilities', + 'selectedK8sContexts', + 'useNotificationHook' +] as const; + +export type MesheryExtensionCapability = (typeof MESHERY_EXTENSION_CAPABILITIES)[number]; + +/** Keys expected on the nested `injectProps.hooks` bag. */ +export const MESHERY_EXTENSION_HOOKS = [ + 'CAN', + 'useDynamicComponent', + 'useFilterK8sContexts' +] as const; + +export type MesheryExtensionHook = (typeof MESHERY_EXTENSION_HOOKS)[number]; + +/** + * Capability keys the host still injects purely so extension bundles published + * against an older contract keep mounting. New code must read the replacement. + * The host is expected to keep supplying these until the corresponding bundles + * age out; dropping one is a breaking contract change. + */ +export const MESHERY_EXTENSION_DEPRECATED_CAPABILITIES = { + capabilitiesRegistry: 'providerCapabilities', + CapabilitiesRegistryClass: 'ProviderUiAccessControlClass', + resolver: null +} as const satisfies Record; + +export type DeprecatedMesheryExtensionCapability = + keyof typeof MESHERY_EXTENSION_DEPRECATED_CAPABILITIES; + +/** + * Fields the host puts on `injectProps` that describe the contract itself rather + * than providing a capability. They are neither required of the host nor useful + * to report as unknown - listing the host's own handshake field as an + * unrecognized key is exactly the kind of noise that teaches maintainers to + * ignore the report. + */ +export const MESHERY_EXTENSION_HANDSHAKE_FIELDS = ['contractVersion'] as const; + +/** Outcome of comparing a host's `injectProps` against the declared contract. */ +export type InjectedCapabilityReport = { + /** + * Set when the host advertised a `contractVersion` differing from the one this + * build declares, which means a rename or removal happened on one side. `host` + * is typed `unknown` because it is untrusted input: a host may advertise + * anything, and anything that is not this build's version is a mismatch. + * + * `null` also covers a host that advertises nothing at all - every host + * deployed before the handshake existed. Those predate the field rather than + * disagreeing about it, so they are reported as the supported legacy case + * instead of drowning the report in mismatches the operator cannot act on. + */ + contractVersionMismatch: { host: unknown; extension: number } | null; + /** Contract keys the host did not provide. Each one is a dead feature. */ + missing: MesheryExtensionCapability[]; + /** Contract hook keys absent from `injectProps.hooks`. */ + missingHooks: MesheryExtensionHook[]; + /** Deprecated keys the host still provides, mapped to their replacement. */ + deprecated: { provided: string; replacement: string | null }[]; + /** + * Keys the host provides that this build has never heard of. Usually benign + * (a newer host), but it is the signal that this bundle predates the host and + * may be missing behaviour the host expects it to have. + */ + unrecognized: string[]; +}; + +/** + * Compares a host-supplied `injectProps` bag against the declared contract. + * + * Kept side-effect free so both sides can use it: the host asserts on it in a + * unit test (catching a prop rename before merge) and extensions run it at mount + * (catching deploy-time host/extension skew, which no CI gate can observe). + */ +export const reportInjectedCapabilities = ( + injectProps: Record | null | undefined +): InjectedCapabilityReport => { + const provided = injectProps ?? {}; + const providedKeys = Object.keys(provided); + const hooks = (provided.hooks ?? {}) as Record; + + const known = new Set([ + ...MESHERY_EXTENSION_CAPABILITIES, + ...MESHERY_EXTENSION_HANDSHAKE_FIELDS, + ...Object.keys(MESHERY_EXTENSION_DEPRECATED_CAPABILITIES) + ]); + + const advertisedVersion = provided.contractVersion; + + return { + contractVersionMismatch: + advertisedVersion === undefined || advertisedVersion === MESHERY_EXTENSION_CONTRACT_VERSION + ? null + : { host: advertisedVersion, extension: MESHERY_EXTENSION_CONTRACT_VERSION }, + missing: MESHERY_EXTENSION_CAPABILITIES.filter(key => provided[key] === undefined), + // `hooks` itself being absent is already reported as a missing capability; + // reporting all three hooks again would be noise, so only look inside when + // the bag is actually there. + missingHooks: + provided.hooks === undefined + ? [] + : MESHERY_EXTENSION_HOOKS.filter(hook => hooks[hook] === undefined), + deprecated: Object.entries(MESHERY_EXTENSION_DEPRECATED_CAPABILITIES) + .filter(([key]) => provided[key] !== undefined) + .map(([provided_, replacement]) => ({ provided: provided_, replacement })), + unrecognized: providedKeys.filter(key => !known.has(key)) + }; +}; + +/** + * True when the host advertised a compatible contract version and satisfied every + * declared capability. Deprecated and unrecognized keys are informational and do + * not fail the check - only an outright missing capability, a missing hook, or a + * version disagreement is a broken contract. + */ +export const isInjectedCapabilityReportSatisfied = (report: InjectedCapabilityReport): boolean => + report.contractVersionMismatch === null && + report.missing.length === 0 && + report.missingHooks.length === 0; + +/** + * Renders a report as a single actionable message naming the offending keys. + * Returns `null` when the contract is satisfied so callers can log unconditionally. + */ +export const describeInjectedCapabilityReport = ( + report: InjectedCapabilityReport +): string | null => { + if (isInjectedCapabilityReportSatisfied(report)) return null; + + const parts: string[] = []; + if (report.contractVersionMismatch !== null) { + const { host, extension } = report.contractVersionMismatch; + parts.push( + `host advertises contract v${String(host)}, this build declares v${extension}` + + ' (a rename or removal happened on one side)' + ); + } + if (report.missing.length > 0) { + parts.push(`missing capabilities: ${report.missing.join(', ')}`); + } + if (report.missingHooks.length > 0) { + parts.push(`missing hooks: ${report.missingHooks.join(', ')}`); + } + // Deliberately no closing claim about *why* they disagree. When a version + // mismatch is known it is already named above; when it is not, the host and + // this bundle disagree while advertising the same version, and asserting a + // version skew would send the reader to compare two numbers that match. + return ( + `Meshery host did not satisfy the extension contract (v${MESHERY_EXTENSION_CONTRACT_VERSION}): ` + + `${parts.join('; ')}. The features these keys back will not work.` + ); +}; diff --git a/src/base/DateTimePicker/DateTimePicker.tsx b/src/base/DateTimePicker/DateTimePicker.tsx index 365e24df6..6adbfb7c4 100644 --- a/src/base/DateTimePicker/DateTimePicker.tsx +++ b/src/base/DateTimePicker/DateTimePicker.tsx @@ -1,19 +1,47 @@ -import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { - DateTimePicker as MuiDateTimePicker, - type DateTimePickerProps as MuiDateTimePickerProps -} from '@mui/x-date-pickers/DateTimePicker'; +import type { DateTimePickerProps as MuiDateTimePickerProps } from '@mui/x-date-pickers/DateTimePicker'; import React from 'react'; -const DateTimePicker = React.forwardRef( - (props, ref) => { - return ( +/** + * `@mui/x-date-pickers` is declared an OPTIONAL peer dependency, and most + * consumers never render a date picker, so most consumers do not install it. + * Importing it at module scope contradicted that: this module is reached from the + * package barrel, so merely writing `import { anything } from '@sistent/sistent'` + * executed a top-level require of the optional package and threw + * `Cannot find module '@mui/x-date-pickers/AdapterDateFns'` for anyone without + * it. In Kanvas that took out four unrelated Jest suites on a clean install, with + * a message pointing at sistent rather than at the missing peer. + * + * Resolving the picker on first render keeps the optional dependency genuinely + * optional: the cost is paid only by code that actually renders one, which is + * what `peerDependenciesMeta.optional` already advertises. + */ +const LazyDateTimePicker = React.lazy(async () => { + const [{ AdapterDateFns }, { LocalizationProvider }, { DateTimePicker: MuiDateTimePicker }] = + await Promise.all([ + import('@mui/x-date-pickers/AdapterDateFns'), + import('@mui/x-date-pickers/LocalizationProvider'), + import('@mui/x-date-pickers/DateTimePicker') + ]); + + const ResolvedDateTimePicker = React.forwardRef( + (props, ref) => ( - ); - } -); + ) + ); + ResolvedDateTimePicker.displayName = 'ResolvedDateTimePicker'; + + return { default: ResolvedDateTimePicker }; +}); + +const DateTimePicker = React.forwardRef((props, ref) => ( + // `null` rather than a spinner: the chunk resolves in a frame or two and a + // flashing placeholder inside a form field reads as a bug. + + + +)); +DateTimePicker.displayName = 'DateTimePicker'; export default DateTimePicker; diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index 5e085e4e8..ea77c74b4 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -5,6 +5,10 @@ import LaunchIcon from '@mui/icons-material/Launch'; import SecurityIcon from '@mui/icons-material/Security'; import React from 'react'; import { EventBus } from '../actors/eventBus'; +import type { + MissingCapabilityReason, + MissingPermissionReason +} from '../actors/mesheryExtensionContract'; import { Box, Chip, ClickAwayListener, Link, Tooltip, Typography } from '../base'; import { usePermissionUserContext } from './PermissionProvider'; @@ -16,19 +20,12 @@ const DIVIDER_SX = { export type InvertAction = 'disable' | 'hide'; -export type MissingPermissionReason = { - type: 'MISSING_PERMISSION'; - data: { - keyId: string; - }; -}; - -export type MissingCapabilityReason = { - type: 'MISSING_CAPABILITY'; - data: { - capabilityId: string; - }; -}; +// These two reason events are published onto whichever bus the caller supplies, +// which in Meshery is the host <-> extension bus. They are therefore part of the +// extension contract and are declared there, so the host's typed bus and every +// extension's subscriber derive them from one place. Re-exported here to keep the +// long-standing `@sistent/sistent` import paths working. +export type { MissingCapabilityReason, MissingPermissionReason }; export type ReasonEvent = MissingPermissionReason | MissingCapabilityReason; diff --git a/src/index.tsx b/src/index.tsx index 638324b5a..608cb743e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -51,6 +51,12 @@ export { type DataTableToolbarProps } from './custom/DataTableToolbar'; +// Same nested-barrel dts-drop quirk as FeedbackButton above. `createCanShow` is +// worse than a missing type: consumers still resolve it at runtime, so the import +// silently degrades to `any` and its `eventBus` argument stops being +// variance-checked - the one place a host hands its event bus to sistent. +export { createCanShow } from './custom/permissions'; + export { PermissionProvider, PermissionShield,