|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `AuthPlugin`'s settings ORDERING contract (#11579) — declared, not incidental. |
| 5 | + * |
| 6 | + * ## What went wrong |
| 7 | + * |
| 8 | + * `SettingsServicePlugin` binds its data engine from a `kernel:ready` hook |
| 9 | + * registered in its `start()`. `AuthPlugin` reaches `getService('settings')` |
| 10 | + * from `kernel:ready` hooks registered in ITS `start()` — at depth 3, through |
| 11 | + * `runBackfill` → `ensureAuthSettingsBound` → `bindAuthSettings` — and calls |
| 12 | + * `settings.getNamespace('auth')` in the same tick. |
| 13 | + * |
| 14 | + * Hooks fire in registration order, and registration order is `start()` order, |
| 15 | + * so whichever plugin starts first registers the earlier hook. Until this |
| 16 | + * change **nothing constrained that order**: `AuthPlugin` declared |
| 17 | + * `dependencies: ['com.objectstack.engine.objectql']` and nothing about |
| 18 | + * settings, and `resolvePluginOrder` preserves insertion order for plugins |
| 19 | + * with no edge between them. On the shipped composition that ordering was not |
| 20 | + * merely unconstrained but WRONG — `os serve` does `kernel.use(new |
| 21 | + * AuthPlugin(...))` before the capability loop registers |
| 22 | + * `SettingsServicePlugin` — so at boot `getNamespace('auth')` took |
| 23 | + * `SettingsService`'s empty in-memory fallback and answered the manifest |
| 24 | + * DEFAULTS with `source: 'default'`, while the workspace's saved `sys_setting` |
| 25 | + * rows sat unread. `settings.subscribe('auth', …)` only re-applies on a LATER |
| 26 | + * change, so a deployment that configured auth in Setup and never touched it |
| 27 | + * again kept booting on defaults. |
| 28 | + * |
| 29 | + * ## The division of labour with `check:settings-bind-window` |
| 30 | + * |
| 31 | + * Two different claims, checked in two different ways, both in CI: |
| 32 | + * |
| 33 | + * - **That the edge names the REAL provider, and that the read is still in |
| 34 | + * the window at all** is `scripts/check-settings-bind-window.mjs`. It walks |
| 35 | + * the TypeScript AST, DERIVES the provider from whoever declares |
| 36 | + * `providesServices: ['settings']`, and fails if a `start()`-registered |
| 37 | + * `kernel:ready` read is not covered by a declaration naming it. That is |
| 38 | + * why `SETTINGS_PLUGIN` below is not cross-checked against the settings |
| 39 | + * package here: a unit test comparing the constant to the same declaration |
| 40 | + * it came from would pass on a typo. The gate is what cannot. |
| 41 | + * - **That the declaration MOVES the order** is this file. A declaration |
| 42 | + * nothing acts on is the defect, not the fix (ADR-0049), and the gate is |
| 43 | + * satisfied by the declaration's presence alone. |
| 44 | + * |
| 45 | + * ## Resolution note |
| 46 | + * |
| 47 | + * `AuthPlugin` is imported from SOURCE (a relative specifier inside this |
| 48 | + * package), which is what this file is a verdict about. `resolvePluginOrder` |
| 49 | + * comes from `@objectstack/core`, a bare workspace specifier already listed in |
| 50 | + * `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/plugin-auth']` |
| 51 | + * (`scripts/check-test-source-alias.mjs`), so it resolves to that package's |
| 52 | + * `dist/` — the ordering algorithm is the fixed instrument here, not the |
| 53 | + * subject. |
| 54 | + * |
| 55 | + * The settings plugin is a NAME-ONLY stub rather than the real |
| 56 | + * `SettingsServicePlugin`: `@objectstack/service-settings` is not a dependency |
| 57 | + * of `@objectstack/plugin-auth`, and adding one so a test could import it |
| 58 | + * would both create a workspace edge that exists for nothing else and force a |
| 59 | + * new entry into the shrink-only registry above. `resolvePluginOrder` reads |
| 60 | + * only the `OrderablePlugin` surface — `name`, `dependencies`, |
| 61 | + * `optionalDependencies` — and the property under test is `AuthPlugin`'s |
| 62 | + * declaration, so the stub is the whole of what the resolver would see. |
| 63 | + */ |
| 64 | + |
| 65 | +import { describe, it, expect } from 'vitest'; |
| 66 | +import { resolvePluginOrder } from '@objectstack/core'; |
| 67 | +import type { OrderablePlugin } from '@objectstack/core'; |
| 68 | +import { AuthPlugin } from './auth-plugin.js'; |
| 69 | + |
| 70 | +const SETTINGS_PLUGIN = 'com.objectstack.service.settings'; |
| 71 | +const ENGINE_PLUGIN = 'com.objectstack.engine.objectql'; |
| 72 | + |
| 73 | +/** |
| 74 | + * `AuthPlugin` declares `com.objectstack.engine.objectql` a HARD dependency, |
| 75 | + * so every registry below has to contain it or `resolvePluginOrder` throws |
| 76 | + * before it can order anything. Name-only: this module orders plugins by their |
| 77 | + * declarations and never runs a lifecycle. |
| 78 | + */ |
| 79 | +const engineStub = (): OrderablePlugin => ({ name: ENGINE_PLUGIN }); |
| 80 | + |
| 81 | +/** See the resolution note in the header for why this is a stub. */ |
| 82 | +const settingsStub = (): OrderablePlugin => ({ name: SETTINGS_PLUGIN }); |
| 83 | + |
| 84 | +const authPlugin = (): OrderablePlugin => |
| 85 | + new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long!!' }) as unknown as OrderablePlugin; |
| 86 | + |
| 87 | +/** Registry in the given insertion order — `resolvePluginOrder` preserves it |
| 88 | + * for plugins with no edges between them, which is what makes the hostile |
| 89 | + * order below hostile. */ |
| 90 | +const registryOf = (...plugins: OrderablePlugin[]) => |
| 91 | + new Map<string, OrderablePlugin>(plugins.map((p) => [p.name, p])); |
| 92 | + |
| 93 | +describe('AuthPlugin declares the settings ordering edge (ADR-0116, #11579)', () => { |
| 94 | + it('1. declares `com.objectstack.service.settings` as an OPTIONAL dependency', () => { |
| 95 | + const auth = authPlugin(); |
| 96 | + expect(auth.optionalDependencies ?? []).toContain(SETTINGS_PLUGIN); |
| 97 | + // Not a hard one: case 4 is the behavioural half of this, but the |
| 98 | + // declaration site is asserted directly too, because promoting the edge to |
| 99 | + // `dependencies` would pass case 2 and 3 while breaking every lean kernel. |
| 100 | + expect(auth.dependencies ?? []).not.toContain(SETTINGS_PLUGIN); |
| 101 | + // The pre-existing hard edge is untouched — this change adds an edge, it |
| 102 | + // does not move one. |
| 103 | + expect(auth.dependencies ?? []).toContain(ENGINE_PLUGIN); |
| 104 | + }); |
| 105 | + |
| 106 | + it('2. the declaration MOVES resolution order — settings inits/starts first even when used last', () => { |
| 107 | + // `ObjectKernel.bootstrap` and `LiteKernel.bootstrap` both iterate the SAME |
| 108 | + // `resolvePluginOrder` output for Phase 1 (init) and Phase 2 (start), so |
| 109 | + // this is the order the `kernel:ready` hooks get registered in. |
| 110 | + const auth = authPlugin(); |
| 111 | + // The shipped hostile order: `os serve` uses AuthPlugin BEFORE the |
| 112 | + // capability loop registers the settings plugin. |
| 113 | + const ordered = resolvePluginOrder(registryOf(engineStub(), auth, settingsStub())) |
| 114 | + .map((p) => p.name); |
| 115 | + expect(ordered.indexOf(SETTINGS_PLUGIN)).toBeLessThan(ordered.indexOf(auth.name)); |
| 116 | + }); |
| 117 | + |
| 118 | + it('3. …and the declaration is what does it — forget it and the order reverts', () => { |
| 119 | + // The ADR-0049 half. Case 2 alone would still pass if `resolvePluginOrder` |
| 120 | + // happened to hoist by some other rule; this proves the DECLARATION is the |
| 121 | + // cause by removing it from a live instance and re-resolving. |
| 122 | + const auth = authPlugin(); |
| 123 | + auth.optionalDependencies = (auth.optionalDependencies ?? []).filter( |
| 124 | + (d) => d !== SETTINGS_PLUGIN, |
| 125 | + ); |
| 126 | + |
| 127 | + const ordered = resolvePluginOrder(registryOf(engineStub(), auth, settingsStub())) |
| 128 | + .map((p) => p.name); |
| 129 | + // Insertion order is preserved for plugins with no edges between them — so |
| 130 | + // with the declaration gone the reader is back in front, which is the |
| 131 | + // defect this card was filed about. |
| 132 | + expect( |
| 133 | + ordered.indexOf(auth.name), |
| 134 | + 'without the declaration auth must come back first — if this passes, case 2 was not measuring the declaration', |
| 135 | + ).toBeLessThan(ordered.indexOf(SETTINGS_PLUGIN)); |
| 136 | + }); |
| 137 | + |
| 138 | + it('4. the edge is SOFT — a kernel with no settings plugin still resolves', () => { |
| 139 | + // `optionalDependencies` is order-if-present. A hard dependency here would |
| 140 | + // refuse to boot every metadata-only / lean kernel that composes auth |
| 141 | + // without a settings service — `bindAuthSettings` already returns early |
| 142 | + // when the service is absent. |
| 143 | + const auth = authPlugin(); |
| 144 | + const registry = registryOf(engineStub(), auth); |
| 145 | + expect(() => resolvePluginOrder(registry)).not.toThrow(); |
| 146 | + expect(resolvePluginOrder(registry).map((p) => p.name)).toContain(auth.name); |
| 147 | + }); |
| 148 | + |
| 149 | + it('5. the edge introduces no cycle — auth is not upstream of settings', () => { |
| 150 | + // `resolvePluginOrder` throws `[Kernel] Circular dependency detected` when |
| 151 | + // both directions are declared, and an optional edge is a real edge |
| 152 | + // whenever both sides are composed. The settings plugin declares only |
| 153 | + // `com.objectstack.engine.objectql`, so this direction is free — asserted |
| 154 | + // rather than assumed, because the check that would otherwise catch it |
| 155 | + // (`check:settings-bind-window`'s `cycle` verdict) reports it as a finding |
| 156 | + // rather than as this plugin's failure. |
| 157 | + const settingsWithItsRealEdge: OrderablePlugin = { |
| 158 | + name: SETTINGS_PLUGIN, |
| 159 | + optionalDependencies: [ENGINE_PLUGIN], |
| 160 | + }; |
| 161 | + const auth = authPlugin(); |
| 162 | + const ordered = resolvePluginOrder(registryOf(engineStub(), auth, settingsWithItsRealEdge)) |
| 163 | + .map((p) => p.name); |
| 164 | + expect(ordered).toEqual([ENGINE_PLUGIN, SETTINGS_PLUGIN, auth.name]); |
| 165 | + }); |
| 166 | +}); |
0 commit comments