From 4ea453ae5509204a2d4bda2a7249bb0db6f4fa1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:07:07 +0000 Subject: [PATCH 1/4] wip(authz-substrate): recovered uncommitted work from a container restart NOT REVIEWED, NOT VERIFIED. The dispatch that wrote this was killed by a container restart before it committed, pushed or ran any gate. This commit exists only so the work survives; the resuming dev is expected to inspect it, not to trust it. Contents as found on disk: 14 modified/added files across packages/core, packages/objectql and packages/plugins/plugin-security. No gate was run against it, no test was run, no ablation exists, and neither of the ruling's two hard requirements (the TTL-is-the-contract note at the channel, and the boot-time posture statement) has been checked for presence. --- .../src/security/authz-cache-posture.test.ts | 167 ++++++++++++++ .../core/src/security/authz-cache-posture.ts | 206 ++++++++++++++++++ .../security/authz-invalidation-channel.ts | 118 ++++++++++ packages/core/src/security/index.ts | 23 ++ .../objectql/src/authz-invalidation-bridge.ts | 119 ++++++++++ packages/objectql/src/engine.ts | 100 ++++++++- packages/objectql/src/index.ts | 21 ++ packages/objectql/src/write-epoch.ts | 146 +++++++++++++ .../plugin-security/src/security-plugin.ts | 60 ++++- .../plugin-security/src/write-epoch-source.ts | 91 ++++++++ packages/runtime/src/runtime.ts | 13 ++ .../src/authz-cluster-bridge-plugin.ts | 168 ++++++++++++++ .../services/service-cluster/src/index.ts | 6 + .../service-cluster/src/split-brain-guard.ts | 19 +- 14 files changed, 1244 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/security/authz-cache-posture.test.ts create mode 100644 packages/core/src/security/authz-cache-posture.ts create mode 100644 packages/core/src/security/authz-invalidation-channel.ts create mode 100644 packages/objectql/src/authz-invalidation-bridge.ts create mode 100644 packages/objectql/src/write-epoch.ts create mode 100644 packages/plugins/plugin-security/src/write-epoch-source.ts create mode 100644 packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts diff --git a/packages/core/src/security/authz-cache-posture.test.ts b/packages/core/src/security/authz-cache-posture.test.ts new file mode 100644 index 0000000000..104bcf769f --- /dev/null +++ b/packages/core/src/security/authz-cache-posture.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + AUTHZ_GRANTS_CACHE_TTL_ENV, + readAuthzGrantsCacheTtlMs, + reportAuthzCachePosture, + resolveAuthzCachePosture, + type AuthzInvalidationBusState, +} from './authz-cache-posture.js'; + +/** + * #11968 — the boot-time posture statement, pinned on BOTH arms. + * + * The acceptance criterion of the substrate card is a biconditional: the line + * appears **exactly when** a cache flag is on without a bus, **and not + * otherwise**. Each arm alone is passed by a broken implementation — "appears" + * alone is satisfied by one that prints always, "silent" alone by one that + * never prints — so both are asserted here, and the exhaustive matrix below is + * what makes "exactly when" a measured claim rather than a described one. + */ + +const BUS_STATES: AuthzInvalidationBusState[] = ['bridged', 'in-process', 'absent']; + +function makeSink() { + return { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; +} + +describe('#11968 authz cache posture — the loud arm', () => { + it('warns when a cache is enabled and NO cluster service is registered', () => { + const sink = makeSink(); + const statement = reportAuthzCachePosture({ ttlMs: 5000, bus: 'absent' }, sink); + + expect(statement.posture).toBe('ttl-only'); + expect(statement.loud).toBe(true); + expect(sink.warn).toHaveBeenCalledTimes(1); + expect(sink.info).not.toHaveBeenCalled(); + }); + + it('warns when a cluster service exists but its driver is in-process', () => { + // The case that would otherwise slip through: `Runtime` registers the + // memory driver by DEFAULT, so "is a cluster service registered?" answers + // yes while the bus fans out to nobody. + const sink = makeSink(); + const statement = reportAuthzCachePosture( + { ttlMs: 5000, bus: 'in-process', driver: 'memory' }, + sink, + ); + + expect(statement.posture).toBe('ttl-only'); + expect(sink.warn).toHaveBeenCalledTimes(1); + expect(sink.warn.mock.calls[0][0]).toContain('memory'); + }); + + it('the loud line names the window, the remedy and that it is not an error', () => { + // A warning nobody can act on gets muted, and a warning that reads as a + // failure gets "fixed" by turning the cache off. Both halves are content. + const { message } = resolveAuthzCachePosture({ ttlMs: 7500, bus: 'absent' }); + + expect(message).toContain('7500ms'); + expect(message).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV); + expect(message).toMatch(/not an error/i); + expect(message).toContain('#4785'); + }); +}); + +describe('#11968 authz cache posture — the silent arm', () => { + it.each(BUS_STATES)( + 'says NOTHING when the cache is disabled (ttl=0, bus=%s)', + (bus) => { + const sink = makeSink(); + const statement = reportAuthzCachePosture({ ttlMs: 0, bus }, sink); + + expect(statement.posture).toBe('disabled'); + expect(statement.message).toBe(''); + expect(sink.warn).not.toHaveBeenCalled(); + expect(sink.info).not.toHaveBeenCalled(); + }, + ); + + it('does not warn when the cache is enabled AND the bus is bridged', () => { + const sink = makeSink(); + const statement = reportAuthzCachePosture( + { ttlMs: 5000, bus: 'bridged', driver: 'redis' }, + sink, + ); + + expect(statement.posture).toBe('bus-narrowed'); + expect(statement.loud).toBe(false); + expect(sink.warn).not.toHaveBeenCalled(); + expect(sink.info).toHaveBeenCalledTimes(1); + }); + + it('a negative TTL is off, not a degenerate enabled cache', () => { + const sink = makeSink(); + expect(reportAuthzCachePosture({ ttlMs: -1, bus: 'absent' }, sink).posture).toBe( + 'disabled', + ); + expect(sink.warn).not.toHaveBeenCalled(); + }); +}); + +describe('#11968 authz cache posture — "exactly when", as a matrix', () => { + // The biconditional itself. Enumerated rather than described, so an + // implementation that prints always or never fails here and not only in prose. + const ttls = [0, 1, 5000]; + const expectedLoud = new Set(['1|in-process', '1|absent', '5000|in-process', '5000|absent']); + + for (const ttlMs of ttls) { + for (const bus of BUS_STATES) { + const key = `${ttlMs}|${bus}`; + const shouldBeLoud = expectedLoud.has(key); + it(`ttl=${ttlMs} bus=${bus} -> ${shouldBeLoud ? 'LOUD' : 'quiet'}`, () => { + const sink = makeSink(); + reportAuthzCachePosture({ ttlMs, bus, driver: 'memory' }, sink); + expect(sink.warn.mock.calls.length > 0).toBe(shouldBeLoud); + }); + } + } +}); + +describe('#11968 grants-cache TTL reading', () => { + it('defaults to 0 — the cache is off unless a deployment turns it on', () => { + expect(readAuthzGrantsCacheTtlMs({})).toEqual({ ttlMs: 0, malformed: false }); + }); + + it('an explicit 0 is a real path, not a degenerate TTL', () => { + expect(readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '0' })).toEqual({ + ttlMs: 0, + raw: '0', + malformed: false, + }); + }); + + it('reads a millisecond count', () => { + expect( + readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: ' 5000 ' }).ttlMs, + ).toBe(5000); + }); + + it('a malformed value is reported as malformed, never folded into "off"', () => { + // `5OOO` with letter O resolving silently to "disabled" is the same + // silent-disable class the posture statement exists to prevent. + const reading = readAuthzGrantsCacheTtlMs({ + [AUTHZ_GRANTS_CACHE_TTL_ENV]: '5OOO', + }); + expect(reading).toEqual({ ttlMs: 0, raw: '5OOO', malformed: true }); + + const sink = makeSink(); + reportAuthzCachePosture( + { ttlMs: reading.ttlMs, bus: 'absent', malformedTtl: { raw: reading.raw } }, + sink, + ); + expect(sink.warn).toHaveBeenCalledTimes(1); + expect(sink.warn.mock.calls[0][0]).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV); + }); + + it('a negative value is malformed, not a clamp', () => { + expect( + readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '-5' }).malformed, + ).toBe(true); + }); +}); diff --git a/packages/core/src/security/authz-cache-posture.ts b/packages/core/src/security/authz-cache-posture.ts new file mode 100644 index 0000000000..bef6b26072 --- /dev/null +++ b/packages/core/src/security/authz-cache-posture.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ── The boot-time authorization-cache posture statement (#11968, #11633 §3) ── + * + * ⭐ **Non-optional**, by the 2026-08-25 ruling on #11633 (Fork 2 → B): whenever + * a grants cache is enabled and there is **no** cross-node invalidation bus, the + * deployment is told so, **out loud**, at boot. + * + * ## Why a statement and not a refusal + * + * A per-process cache bounded only by its TTL is a legitimate configuration — + * #11633 §3 rules the TTL, not the bus, as the correctness contract, so a + * single-node deployment (or one that simply accepts the window) is correct + * with no bus at all. What is NOT acceptable is arriving there **without + * noticing**: that is the shape of #4785, where a control was silently disabled + * by configuration and nothing said so. The metadata bridge logs its own + * absence at `debug` and that is right for metadata — a missed + * `metadata.changed` costs a stale schema until reload and loses no data. Here + * the same silence would cost a permission honoured past its revocation. + * + * ⇒ Enabled cache + no bus is a `warn`, every boot, naming the window it just + * accepted. Not a refusal — a statement. + * + * ## The three postures, and the reason `disabled` is silent + * + * - `disabled` — no cache is enabled. **Silent.** There is no window to + * state, and a line every boot on the shipped default + * (TTL `0`, Fork 4) would train operators to ignore it — + * which is how the loud line stops being loud. + * - `ttl-only` — cache enabled, no cross-node bus. **LOUD (`warn`).** + * - `bus-narrowed` — cache enabled, bus bridged. `info`, so the bridge's + * presence is on the record next to its absence. + * + * Both arms are pinned in `authz-cache-posture.test.ts`: a statement that + * appears always is no more useful than one that never appears. + */ + +/** Deployment variable that turns the grants cache on. `0` (default) = off. */ +export const AUTHZ_GRANTS_CACHE_TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; + +/** + * What the local node has, in cross-node terms, for delivering + * `authz.invalidated`. + * + * ⚠️ `in-process` is a distinct state on purpose, and it is the one that would + * otherwise go unnoticed: `Runtime` auto-registers a **memory** cluster service + * by default, so "is a `cluster` service registered?" answers *yes* on the + * shipped default while the bus fans out to exactly nobody + * (`service-cluster/src/memory/pubsub.ts`: *"No cross-process delivery"*; the + * split-brain guard calls the same set `IN_PROCESS_DRIVERS`). A posture check + * that asked only whether a service exists would therefore stay silent in + * precisely the multi-replica deployment it exists to warn. + */ +export type AuthzInvalidationBusState = + /** A cross-node transport is attached and carrying the channel. */ + | 'bridged' + /** A cluster service exists, but its driver does not cross a process boundary. */ + | 'in-process' + /** No cluster service, or no engine seam to attach one to. */ + | 'absent'; + +/** The posture a boot resolves to. */ +export type AuthzCachePosture = 'disabled' | 'ttl-only' | 'bus-narrowed'; + +export interface AuthzCachePostureInput { + /** Configured grants-cache TTL in ms. `<= 0` means the cache is off. */ + ttlMs: number; + /** What the node has for cross-node invalidation. */ + bus: AuthzInvalidationBusState; + /** Cluster driver name, when one is registered. Surfaced in the message. */ + driver?: string; +} + +export interface AuthzCachePostureStatement { + posture: AuthzCachePosture; + /** True when this must be said at `warn`. See the module doc. */ + loud: boolean; + /** The statement itself. Empty only for the silent `disabled` posture. */ + message: string; +} + +/** + * Resolve the posture. Pure — it reads its inputs and nothing else, so both + * arms of the acceptance criterion ("appears exactly when a cache flag is on + * without a bus, and not otherwise") are testable without a boot. + */ +export function resolveAuthzCachePosture( + input: AuthzCachePostureInput, +): AuthzCachePostureStatement { + const { ttlMs, bus, driver } = input; + + if (!(ttlMs > 0)) { + return { posture: 'disabled', loud: false, message: '' }; + } + + if (bus === 'bridged') { + return { + posture: 'bus-narrowed', + loud: false, + message: + `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with the ` + + `"authz.invalidated" bridge attached` + + (driver ? ` (cluster driver "${driver}")` : '') + + '. The bus narrows the TYPICAL convergence to one network hop; the TTL ' + + 'remains the correctness bound, because no shipped driver delivers ' + + 'better than at-most-once (cluster.mdx §4.2).', + }; + } + + const why = + bus === 'in-process' + ? `the cluster driver "${driver ?? 'memory'}" is in-process and does not ` + + 'fan out across replicas' + : 'no cluster service is registered on this node'; + + return { + posture: 'ttl-only', + loud: true, + message: + `[authz-cache] grants cache ENABLED (ttl=${ttlMs}ms) with NO ` + + `"authz.invalidated" invalidation bus — ${why}. A grant revoked on ` + + `another replica is honoured by this one for up to ${ttlMs}ms. That is a ` + + 'supported configuration, not an error: the TTL is the correctness bound ' + + 'and it still holds. It is stated because a silently-absent invalidation ' + + 'bridge is how a security control gets disabled without anyone noticing ' + + `(#4785). To narrow the typical window, configure a remote cluster driver; ` + + `to remove it entirely, set ${AUTHZ_GRANTS_CACHE_TTL_ENV}=0.`, + }; +} + +/** The reading of {@link AUTHZ_GRANTS_CACHE_TTL_ENV}, malformed input included. */ +export interface AuthzGrantsCacheTtlReading { + /** The effective TTL. `0` whenever the cache is off — including malformed. */ + ttlMs: number; + /** The raw value read, when one was set. */ + raw?: string; + /** True when a value was set but could not be read as a non-negative number. */ + malformed: boolean; +} + +/** + * Read the grants-cache TTL from deployment config. + * + * Deployment config, never a settings row (#11633 §5): the knob that bounds a + * cache must not itself be served through a cached path, and operator-level + * configuration comes from the environment. + * + * Default `0` — the grants cache is **off** unless a deployment turns it on and + * accepts the staleness window explicitly (#11633 Fork 4, ruled 2026-08-25). + * + * ⚠️ A malformed value resolves to `0` but is reported as malformed rather than + * folded into "off": `OS_AUTHZ_GRANTS_CACHE_TTL_MS=5OOO` (letter O) silently + * meaning "disabled" is the same silent-disable class the posture statement + * exists to prevent. + */ +export function readAuthzGrantsCacheTtlMs( + env: Record = typeof process !== 'undefined' + ? process.env + : {}, +): AuthzGrantsCacheTtlReading { + const raw = env[AUTHZ_GRANTS_CACHE_TTL_ENV]; + if (raw === undefined || raw.trim() === '') { + return { ttlMs: 0, malformed: false }; + } + const parsed = Number(raw.trim()); + if (!Number.isFinite(parsed) || parsed < 0) { + return { ttlMs: 0, raw, malformed: true }; + } + return { ttlMs: Math.floor(parsed), raw, malformed: false }; +} + +/** Minimal sink shape — `warn` is the member every logger in this repo has. */ +export interface AuthzPostureSink { + warn(message: string, meta?: Record): void; + info?(message: string, meta?: Record): void; + debug?(message: string, meta?: Record): void; +} + +/** + * State the posture at boot. `warn` for the loud arm, `info` for the bridged + * one, and nothing at all when no cache is enabled (see the module doc for why + * silence is the right default rather than a courtesy line). + * + * A malformed TTL value is warned about on its own, because "we read your + * setting as off" is exactly what a deployment must not have to infer. + */ +export function reportAuthzCachePosture( + input: AuthzCachePostureInput & { malformedTtl?: { raw?: string } }, + sink: AuthzPostureSink, +): AuthzCachePostureStatement { + if (input.malformedTtl) { + sink.warn( + `[authz-cache] ${AUTHZ_GRANTS_CACHE_TTL_ENV}=` + + `${JSON.stringify(input.malformedTtl.raw ?? '')} is not a non-negative ` + + 'number; the grants cache is treated as DISABLED. Set a millisecond ' + + 'count, or 0 to disable it deliberately.', + ); + } + + const statement = resolveAuthzCachePosture(input); + if (statement.posture === 'disabled') return statement; + if (statement.loud) sink.warn(statement.message); + else sink.info?.(statement.message); + return statement; +} diff --git a/packages/core/src/security/authz-invalidation-channel.ts b/packages/core/src/security/authz-invalidation-channel.ts new file mode 100644 index 0000000000..95dd09f9cc --- /dev/null +++ b/packages/core/src/security/authz-invalidation-channel.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ── The `authz.invalidated` cluster channel (#11968, #11633 §3) ───────────── + * + * The cross-node half of the authorization invalidation substrate: one channel + * name, one payload shape, and the contract statement that governs how both may + * be read. It carries no cache and no consumer — leg B (#11967) is the first. + * + * ## ⭐ THE TTL IS THE CORRECTNESS CONTRACT. THIS CHANNEL IS NOT. + * + * A message on this channel is a **hint**, and **a missed message is EXPECTED**. + * That is not a caveat about an unreliable network; it is the shipped + * guarantee, measured rather than assumed: + * + * - `content/docs/kernel/cluster.mdx` §4.2, on `at-least-once`: + * *"**No shipped driver provides this yet.** The `redis` driver publishes + * over plain Redis pub/sub, which is *at-most-once* — fire-and-forget, no + * persistence, no replay for a node that was down at publish time."* + * - `@objectstack/service-cluster-redis`'s own `publish` docblock: + * *"there is no delivery guarantee to subscribers and no replay for a node + * that was down or slow at publish time. This is acceptable **only** for + * events that are pure cache-invalidation hints, never the source of + * truth."* + * - The `memory` driver does not cross a process boundary at all + * (`service-cluster/src/memory/pubsub.ts`, and the split-brain guard's + * `IN_PROCESS_DRIVERS`). + * + * So a dropped message on an at-most-once transport is a staleness window with + * **no upper bound**, and no amount of care at the publish site changes that. + * What bounds it is the **TTL** every cached authorization answer must carry: + * a peer that never hears the message still converges when its entry expires. + * + * ⇒ **A consumer that would be incorrect if a message were lost is misusing + * this channel.** The channel exists for one thing: moving the *typical* + * convergence from "one TTL" down to "one network hop". It never moves the + * worst case, and it is never the mechanism that makes a cached authorization + * answer safe. + * + * ⚠️ For the same reason this channel is **best-effort at the publish site + * too**: a publish failure is logged and swallowed, never propagated into the + * write that triggered it. A grant revocation must not fail because a cache + * hint could not be delivered — the TTL already covers exactly that case. + * + * ⚠️ Known contradiction in the surrounding docs, recorded so nobody resolves it + * the wrong way: `IPubSub`'s own interface docblock + * (`@objectstack/spec/contracts`) still says *"At-least-once delivery"*, which + * no shipped driver provides. `cluster.mdx` §4.2 and the redis driver are the + * measured statements and are the ones this module follows. Repairing that + * docblock is a `packages/spec` change and is filed separately, deliberately + * not made here. + * + * ## Why a new channel on the existing bus, and not a new transport + * + * `MetadataClusterBridgePlugin` already shows the whole shape — a channel on + * `IPubSub`, bridged by a plugin that late-binds at `kernel:ready` and does + * nothing when the services it needs are absent. Reusing it adds no dependency + * and no new failure mode. The one thing metadata's channel does NOT have to + * carry is what makes this one different: a missed `metadata.changed` costs a + * stale schema until reload and loses no data, while a missed + * `authz.invalidated` would cost a permission honoured past its revocation — + * which is why the bound lives in the TTL and why the absence of this bridge is + * stated out loud at boot ({@link ../security/authz-cache-posture.js}). + */ + +/** + * The cluster channel authorization-cache invalidation hints travel on. + * + * Named as a fact about authorization, not about any one cache, because a + * second consumer must reuse this channel rather than mint a parallel one — + * two channels would be two chances to miss a bridge. + */ +export const AUTHZ_INVALIDATED_CHANNEL = 'authz.invalidated'; + +/** + * Why an authorization epoch advanced. Coarse on purpose (#11633 §2.2, Fork 1 → + * A): the engine seam sees `update`/`delete` expressed as a `where`, from which + * the affected user or organization is frequently **not derivable without + * reading the row back**. So the substrate carries "something authorization- + * relevant changed", never "whose entry to drop", and a consumer retires its + * whole bucket. Keyed invalidation is gated behind a measurement of a + * write-heavy tenant and is explicitly not the starting point. + */ +export type AuthzInvalidationReason = + /** A write (`insert` / `update` / `delete`) passed the engine middleware seam. */ + | 'write' + /** A metadata change — a permission set can be DECLARED, so no row is written. */ + | 'metadata' + /** A hint received from a peer node on this channel. */ + | 'remote' + /** An explicit bump by a host that knows something the seam cannot see. */ + | 'manual'; + +/** + * The payload on {@link AUTHZ_INVALIDATED_CHANNEL}. + * + * Deliberately tiny and deliberately NOT a description of what to invalidate: + * see {@link AuthzInvalidationReason} for why the seam cannot supply that. A + * receiver's only correct response is to retire its authorization cache + * wholesale — and to remain correct if this message never arrives. + * + * ⛔ Not a `packages/spec` contract type. #11633 §5 reserves a declared shape + * for the invalidation event to the spec seat and does not pre-commit it; this + * is the runtime shape the substrate publishes today. + */ +export interface AuthzInvalidatedPayload { + /** + * Publishing node, for loopback suppression — a node must not act on its own + * hint. Mirrors `ClusterMetadataChangedPayload.originNode`. + */ + originNode?: string; + /** The publisher's local epoch after the bump. Diagnostic only. */ + epoch: number; + /** What advanced the epoch. Diagnostic only — see the type's doc. */ + reason: AuthzInvalidationReason; + /** Wall-clock publish time, ms since epoch. Best-effort. */ + at: number; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 2ab70e338b..c32bcb8602 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -177,3 +177,26 @@ export { OPERATION_PRIVATE_KEY_PREFIX, withoutOperationPrivateKeys, } from './operation-private-keys.js'; + +// [#11968] ADR-0127-shaped authorization caching SUBSTRATE — the cross-node +// channel contract and the boot-time posture statement (#11633 §3, Fork 2 → B, +// ruled 2026-08-25). No cache lives here and nothing consumes these yet; leg B +// (#11967) is the first consumer. Read the channel module before using either: +// the TTL is the correctness contract, and a missed message is EXPECTED. +export { + AUTHZ_INVALIDATED_CHANNEL, + type AuthzInvalidationReason, + type AuthzInvalidatedPayload, +} from './authz-invalidation-channel.js'; +export { + AUTHZ_GRANTS_CACHE_TTL_ENV, + resolveAuthzCachePosture, + readAuthzGrantsCacheTtlMs, + reportAuthzCachePosture, + type AuthzInvalidationBusState, + type AuthzCachePosture, + type AuthzCachePostureInput, + type AuthzCachePostureStatement, + type AuthzGrantsCacheTtlReading, + type AuthzPostureSink, +} from './authz-cache-posture.js'; diff --git a/packages/objectql/src/authz-invalidation-bridge.ts b/packages/objectql/src/authz-invalidation-bridge.ts new file mode 100644 index 0000000000..80abdf445f --- /dev/null +++ b/packages/objectql/src/authz-invalidation-bridge.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + AUTHZ_INVALIDATED_CHANNEL, + type AuthzInvalidatedPayload, +} from '@objectstack/core'; +import type { IPubSub } from '@objectstack/spec/contracts'; +import type { WriteEpochLike } from './write-epoch.js'; + +/** + * ── Bridging the engine-seam write epoch onto `authz.invalidated` ─────────── + * + * The wiring half of the substrate: local epoch bumps go out on the channel, + * peers' hints come back in and advance the local epoch. Shaped after + * `MetadataClusterBridgePlugin` deliberately — a channel on the existing + * `IPubSub`, a loopback guard on `originNode`, and no new transport. + * + * ⭐ **A missed message is EXPECTED, and this bridge is written to survive it.** + * Read `authz-invalidation-channel.ts` in `@objectstack/core` before changing + * anything here: no shipped driver delivers better than at-most-once + * (`cluster.mdx` §4.2), so the TTL on the consuming cache — never this bridge — + * is what bounds staleness. Two consequences are load-bearing in the code + * below and are not incidental defensiveness: + * + * 1. **A publish failure is logged and swallowed.** It must never propagate + * into the write that triggered it: failing a grant revocation because a + * cache *hint* could not be delivered would trade a bounded staleness + * window for an unbounded outage. The TTL already covers the miss. + * 2. **The publish is not awaited by the writer.** The epoch has already + * advanced locally by the time this runs, so the local node is correct + * regardless of what the network does next. + * + * ⚠️ Attach this only when a cache is actually enabled. It publishes one small + * message per write, which buys nothing if nothing is caching — and the + * substrate's own acceptance criterion is that runtime behaviour is unchanged + * while there are no consumers. + */ +export interface AuthzInvalidationBridgeOptions { + /** The engine's epoch — the local source of truth for "something changed". */ + epoch: WriteEpochLike; + /** The cluster bus. Its delivery guarantee is at-most-once; see above. */ + pubsub: IPubSub; + /** This node's cluster id, used for loopback suppression. */ + nodeId: string; + /** Optional sink for publish failures and attach/detach notes. */ + logger?: { + debug?(message: string, meta?: Record): void; + warn?(message: string, meta?: Record): void; + }; +} + +/** + * Wire `epoch` to `pubsub` in both directions. + * + * @returns a disposer that unsubscribes both directions. Idempotent. + */ +export function bridgeAuthzInvalidation( + options: AuthzInvalidationBridgeOptions, +): () => void { + const { epoch, pubsub, nodeId, logger } = options; + + const unsubscribeRemote = pubsub.subscribe( + AUTHZ_INVALIDATED_CHANNEL, + (msg) => { + const payload = msg?.payload; + // Loopback guard — never act on a hint this node published. + if (payload?.originNode && payload.originNode === nodeId) return; + // Coarse by construction: the payload says something changed, never what + // to drop, so the only correct response is to advance the local epoch and + // let consumers retire everything they hold. + epoch.bump('remote'); + }, + ); + + const unsubscribeLocal = epoch.subscribe((current, reason) => { + // A bump caused by a peer's hint must not be echoed back onto the bus — + // two bridged nodes would otherwise trade one write forever. + if (reason === 'remote') return; + + const payload: AuthzInvalidatedPayload = { + originNode: nodeId, + epoch: current, + reason, + at: Date.now(), + }; + + // Fire-and-forget, both branches on purpose — see the module doc. + try { + void Promise.resolve( + pubsub.publish( + AUTHZ_INVALIDATED_CHANNEL, + payload, + ), + ).catch((err: unknown) => { + logger?.debug?.('authz.invalidated publish failed (hint lost; TTL bounds it)', { + channel: AUTHZ_INVALIDATED_CHANNEL, + error: err instanceof Error ? err.message : String(err), + }); + }); + } catch (err) { + logger?.debug?.('authz.invalidated publish threw (hint lost; TTL bounds it)', { + channel: AUTHZ_INVALIDATED_CHANNEL, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + try { + unsubscribeRemote(); + } catch { + // A driver whose disposer throws must not strand the other direction. + } + unsubscribeLocal(); + }; +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d45aff81c2..35e39f2b7f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -61,7 +61,7 @@ import type { FlowFunctionEffect } from '@objectstack/spec/automation'; // Imported from spec directly rather than through `@objectstack/core`'s // re-export block: that block is labelled backward-compatibility, and this // contract is new (#5945). -import type { IScopedContext, IScopedObjectRepository, IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts'; +import type { IPubSub, IScopedContext, IScopedObjectRepository, IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts'; import { IDataDriver, IDataEngine, @@ -80,6 +80,8 @@ import { // from importing `@objectstack/metadata-protocol`, where it was written. recordNotFoundError, } from '@objectstack/core'; +import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; +import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js'; import { CrossDatasourceTransactionWriteError, TransactionUnsupportedError } from './transaction-errors.js'; import { @@ -3206,6 +3208,84 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered held-file resolver for sys_file hydration'); } + /** + * [#11968] The engine-seam write epoch — the invalidation substrate of the + * ruled authorization caching design (#11633 §2.1, §3). + * + * A monotonic counter advanced by {@link executeWithMiddleware} on every + * `insert` / `update` / `delete` that passes the middleware chain. It is a + * generalisation of the private counter `@objectstack/plugin-security` has + * carried since #10757: the mechanism was always the engine's, and hoisting + * it here is what lets a second consumer share ONE signal instead of minting + * a parallel one that watches a different set of writes. + * + * ⭐ It is a SEAM, not a call-site list. A path that grants a capability + * cannot forget to invalidate, because writing through this engine is the + * only way to write at all. See `write-epoch.ts` for why that property is the + * one to protect when editing, and for what the epoch deliberately does not + * carry (anything about WHICH entry to drop). + * + * ⛔ The epoch alone is not a licence to cache. It sees writes THIS process + * makes; a peer node's grant revocation is invisible to it. A cached + * authorization answer additionally needs a TTL bound — see + * `authz-invalidation-channel.ts` in `@objectstack/core`. + */ + readonly writeEpoch = new WriteEpoch(); + + /** Disposer for the `authz.invalidated` bridge, when one is attached. */ + private authzInvalidationDetach?: () => void; + /** The `(pubsub, nodeId)` pair currently bridged, for idempotent re-attach. */ + private authzInvalidationBinding?: { pubsub: unknown; nodeId: string }; + + /** + * [#11968] Attach a cluster pub/sub transport so this engine's write epoch + * fans out on the `authz.invalidated` channel and peers' hints advance it + * locally. Mirrors `MetadataManager.attachClusterPubSub()`, including its + * idempotency on the `(pubsub, nodeId)` pair, and is called the same way — by + * a bridge plugin in `@objectstack/service-cluster`, once per kernel boot, + * after both services exist. + * + * ⚠️ Attach only when something is actually caching authorization answers. + * The bridge publishes one message per write, which buys nothing with no + * consumer — and the substrate's acceptance criterion is that runtime + * behaviour is unchanged until leg B (#11967) lands. + * + * ⭐ Attaching this does NOT make a cached answer safe. Delivery is + * at-most-once on every shipped driver, so a missed message is expected and + * the consuming TTL remains the correctness bound. The channel module in + * `@objectstack/core` carries the full statement. + * + * @returns a disposer that detaches the bridge. + */ + attachAuthzInvalidationPubSub(pubsub: IPubSub, nodeId: string): () => void { + if ( + this.authzInvalidationBinding?.pubsub === pubsub && + this.authzInvalidationBinding?.nodeId === nodeId + ) { + return () => this.detachAuthzInvalidationPubSub(); + } + this.detachAuthzInvalidationPubSub(); + this.authzInvalidationDetach = bridgeAuthzInvalidation({ + epoch: this.writeEpoch, + pubsub, + nodeId, + logger: this.logger, + }); + this.authzInvalidationBinding = { pubsub, nodeId }; + this.logger.info('ObjectQL attached to the authz.invalidated cluster channel', { + nodeId, + }); + return () => this.detachAuthzInvalidationPubSub(); + } + + /** Tear down the `authz.invalidated` bridge. Safe to call multiple times. */ + detachAuthzInvalidationPubSub(): void { + const detach = this.authzInvalidationDetach; + this.authzInvalidationDetach = undefined; + this.authzInvalidationBinding = undefined; + detach?.(); + } + /** * Register a middleware function * Middlewares execute in onion model around every data operation. @@ -3221,6 +3301,24 @@ export class ObjectQL implements IObjectQLEngine { * Execute an operation through the middleware chain */ private async executeWithMiddleware(ctx: OperationContext, executor: () => Promise): Promise { + // [#11968] Advance the write epoch FIRST — ahead of every middleware, and + // so ahead of any `isSystem` bypass one of them applies. The writes most + // likely to change what a caller may see are system ones: the platform + // seeder, a package publish, the auto-org-admin grant. A guard that only + // saw user writes would leave cached authorization standing across exactly + // the grants that matter. (`@objectstack/plugin-security` made the same + // choice inside its own middleware in #10757; hoisting it here is what + // makes the covered set identical BY CONSTRUCTION to "everything the + // middleware chain sees", instead of identical by two authors agreeing.) + // + // ⛔ Do not move this below the `applicable` filter: that filter is a + // per-object middleware selector, and an epoch that only advanced when some + // middleware happened to be registered for the written object would be a + // seam with holes in it. + if (isWriteEpochOperation(ctx.operation)) { + this.writeEpoch.bump('write'); + } + const applicable = this.middlewares.filter(m => !m.object || m.object === '*' || m.object === ctx.object ); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 979c4e60f9..075b82b8b8 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -369,3 +369,24 @@ export type { // ADR-0038 L3 — post-publish runtime probes (one real read per published // artifact); findings are BuildIssue-shaped with layer 'runtime'. + +// [#11968] The authorization caching SUBSTRATE — the engine-seam write epoch +// and the `authz.invalidated` bridge (#11633 §2.1/§3, Fork 2 → B, ruled +// 2026-08-25). Nothing here caches anything; leg B (#11967) is the first +// consumer. ⭐ Read `write-epoch.ts` and `@objectstack/core`'s +// `authz-invalidation-channel.ts` before consuming either: the epoch sees only +// THIS process's writes, delivery on the channel is at-most-once, and the TTL +// on the consuming cache — never this substrate — is the correctness contract. +export { + WriteEpoch, + isWriteEpochLike, + isWriteEpochOperation, + WRITE_EPOCH_OPERATIONS, +} from './write-epoch.js'; +export type { + WriteEpochLike, + WriteEpochListener, + WriteEpochUnsubscribe, +} from './write-epoch.js'; +export { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; +export type { AuthzInvalidationBridgeOptions } from './authz-invalidation-bridge.js'; diff --git a/packages/objectql/src/write-epoch.ts b/packages/objectql/src/write-epoch.ts new file mode 100644 index 0000000000..c40a7af090 --- /dev/null +++ b/packages/objectql/src/write-epoch.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AuthzInvalidationReason } from '@objectstack/core'; + +/** + * ── The engine-seam write epoch (#11968, #11633 §2.1) ─────────────────────── + * + * A monotonic counter that advances whenever something happens that could + * change the answer to an authorization question. Cache-shaped consumers read + * it, remember the value they resolved at, and treat an entry as retired the + * moment the counter has moved. + * + * This is a generalisation of the private `writeEpoch` field + * `@objectstack/plugin-security` has carried since #10757. The mechanism was + * always the engine's; only the counter lived in one plugin. Moving it here is + * what lets a second consumer share ONE invalidation signal instead of minting + * a parallel one that observes a different set of writes. + * + * ## ⭐ Why a SEAM, and never a list of call sites + * + * The alternative shape — "the places that must remember to invalidate" — is a + * permanent maintenance obligation whose failure mode is **silent + * over-permission**: a new grant-granting path forgets to invalidate, and the + * only symptom is a permission served after it was revoked. The engine seam + * cannot be forgotten, because writing through the engine is the only way to + * write at all — including better-auth's own adapter, which routes membership + * changes, bans and session revocation straight through + * `insert`/`update`/`delete` (`plugin-auth/src/objectql-adapter.ts`). + * + * That is the "declared = enforced" shape, and it is the property to protect + * when changing this file: any edit that turns the seam back into an opt-in + * call is a regression even if every existing test stays green. + * + * ## What it deliberately does NOT carry + * + * Nothing about *which* entry to drop. For an `update`/`delete` expressed as a + * `where`, the affected `user_id` / `organization_id` is frequently not + * derivable without reading the row back, so a consumer that tried to key on it + * would be guessing (#11633 §2.2). Coarse is the ruled baseline (Fork 1 → A): + * any write retires everything, and keyed invalidation must first be justified + * by a measurement of a write-heavy tenant. + * + * ## Not, by itself, a licence to cache + * + * The epoch bounds nothing on its own. It observes writes **this process** + * sees; another node's grant revocation is invisible to it. A cached + * authorization answer needs the TTL as well — see + * `authz-invalidation-channel.ts` in `@objectstack/core` for why the bus is a + * narrowing and never the bound. + */ + +/** A listener called after each bump. Never called with the pre-bump value. */ +export type WriteEpochListener = ( + epoch: number, + reason: AuthzInvalidationReason, +) => void; + +/** Disposer returned by {@link WriteEpoch.subscribe}. Idempotent. */ +export type WriteEpochUnsubscribe = () => void; + +/** + * The structural shape a consumer needs. Declared separately so packages that + * do NOT depend on `@objectstack/objectql` — `@objectstack/plugin-security` + * among them — can feature-detect the engine's epoch without an import, the + * same way the metadata cluster bridge feature-detects `attachClusterPubSub()`. + */ +export interface WriteEpochLike { + readonly current: number; + bump(reason: AuthzInvalidationReason): number; + subscribe(listener: WriteEpochListener): WriteEpochUnsubscribe; +} + +/** True when `value` carries the {@link WriteEpochLike} surface. */ +export function isWriteEpochLike(value: unknown): value is WriteEpochLike { + if (typeof value !== 'object' || value === null) return false; + const v = value as Partial; + return ( + typeof v.current === 'number' && + typeof v.bump === 'function' && + typeof v.subscribe === 'function' + ); +} + +export class WriteEpoch implements WriteEpochLike { + private epoch = 0; + private readonly listeners = new Set(); + + /** The current epoch. Starts at `0` and only ever increases. */ + get current(): number { + return this.epoch; + } + + /** + * Advance the epoch and notify subscribers. Returns the new value. + * + * ⚠️ Listener errors are swallowed on purpose. A subscriber is a cache or a + * bus bridge; neither may fail the write that triggered the bump. The counter + * has already advanced by the time any listener runs, so an exploding + * listener leaves the invalidation itself intact — over-invalidating in the + * worst case, which is the safe direction. + */ + bump(reason: AuthzInvalidationReason): number { + this.epoch += 1; + if (this.listeners.size > 0) { + const at = this.epoch; + for (const listener of [...this.listeners]) { + try { + listener(at, reason); + } catch { + // Deliberately ignored — see the docblock above. + } + } + } + return this.epoch; + } + + /** Observe every subsequent bump. Call the returned disposer to stop. */ + subscribe(listener: WriteEpochListener): WriteEpochUnsubscribe { + this.listeners.add(listener); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + this.listeners.delete(listener); + }; + } + + /** How many listeners are attached. Diagnostics and tests. */ + get listenerCount(): number { + return this.listeners.size; + } +} + +/** + * The engine operations that advance the epoch. + * + * ⚠️ Read operations are deliberately absent, and adding one would not be a + * tightening: an epoch that moved on reads would retire every entry on every + * request, which reads as "the cache never hits" rather than as a failure. + */ +export const WRITE_EPOCH_OPERATIONS = new Set(['insert', 'update', 'delete']); + +/** True when `operation` is a write the seam counts. */ +export function isWriteEpochOperation(operation: unknown): boolean { + return typeof operation === 'string' && WRITE_EPOCH_OPERATIONS.has(operation); +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 27a2dcb311..54cbd3ed2b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -11,6 +11,11 @@ import { markFilterSubtreeProvenance, FieldMaskingRuleSchema, type FieldMaskingR // operation-level refusals. Second consumer, same mechanism — a second remedy // for one defect class is what that module exists to prevent. import { renderOperationMessage } from '@objectstack/spec/system'; +import { + localWriteEpochSource, + resolveWriteEpochSource, + type WriteEpochSource, +} from './write-epoch-source.js'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { composeHumanBaselinePermissionSets, PLATFORM_BASELINE_PERMISSION_SET } from './app-default-permission-set.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; @@ -883,12 +888,17 @@ export class SecurityPlugin implements Plugin { * de-duplication: a context that performs a WRITE and then reads again is * entitled to see the write. A permission change lands as a write to * `sys_permission_set` / `sys_user_permission_set` / `sys_position_*` through - * this very engine, so {@link SecurityPlugin.writeEpoch} is bumped on every - * write operation the middleware sees -- before the `isSystem` bypass, so a + * this very engine, so {@link SecurityPlugin.epoch} is bumped on every write + * operation the engine seam sees -- before the `isSystem` bypass, so a * seeder, a package publish or the auto-org-admin grant invalidates too -- and * an entry is reused only while the epoch it was resolved at still stands. * Any write, by ANY context in this process, retires every entry. * + * [#11968] That bump now happens in the ENGINE, ahead of the whole middleware + * chain, rather than at the head of this plugin's middleware. The covered set + * is unchanged -- it is the same seam -- but it is now covered BY + * CONSTRUCTION rather than by two files agreeing on which operations count. + * * What that leaves is exactly one thing: two reads by one context with no * intervening write, which is the find/count pair above and is the definition * of a duplicate question. It is NOT a cache -- nothing here survives a write, @@ -899,11 +909,19 @@ export class SecurityPlugin implements Plugin { { epoch: number; key: string; sets: Promise } >(); /** - * [#10757] Monotonic counter bumped on every engine WRITE this middleware - * sees. Read only by {@link SecurityPlugin.permissionSetMemo}; see its doc for - * why the guard exists and what it is guarding against. + * [#10757, extracted #11968] Monotonic counter read by + * {@link SecurityPlugin.permissionSetMemo}; see its doc for why the guard + * exists and what it is guarding against. + * + * The counter itself is no longer this plugin's. #11633 §10.3 hoisted the + * mechanism into the engine (`ObjectQL.writeEpoch`), so every consumer of + * "something authorization-relevant changed" shares ONE signal instead of + * each watching its own subset of writes. {@link resolveWriteEpochSource} + * binds to the engine's seam when the wired engine exposes it and falls back + * to a private counter when it does not — the pre-`start()` value here is + * that fallback, so the memo is never keyed on `undefined`. */ - private writeEpoch = 0; + private epoch: WriteEpochSource = localWriteEpochSource(); /** * This plugin's report sink. Console-backed until a host injects one — see * {@link SecurityReportSink} and {@link CONSOLE_SECURITY_SINK} for the ruling @@ -1033,6 +1051,12 @@ export class SecurityPlugin implements Plugin { // engine middleware AND the public getReadFilter service method. this.metadata = metadata; this.ql = ql; + + // [#11968] Bind the invalidation epoch to the ENGINE's seam when the wired + // engine exposes one. Resolved here, once, rather than probed per request: + // the plugin DI graph is static after start, and a per-request probe would + // let the memo key on one counter and the invalidation land on another. + this.epoch = resolveWriteEpochSource(ql); this.rlsCompiler.setLogger?.(ctx.logger); // [C2 / ADR-0095] Late-bound resolver for the optional `sharing` service. this.resolveKernelService = (name: string) => { @@ -1055,7 +1079,11 @@ export class SecurityPlugin implements Plugin { // the per-context memo here keeps it on the same invalidation footing // as every other metadata-derived cache above, rather than being the // one that survives a Studio edit. - this.writeEpoch++; + // + // [#11968] This bump stays here whichever side owns the epoch: it is + // precisely the invalidation the ENGINE seam cannot see, because no row + // is written. The engine covers writes; this covers declarations. + this.epoch.bump('metadata'); }); } @@ -1459,8 +1487,18 @@ export class SecurityPlugin implements Plugin { // system ones: the platform seeder, a package publish, the auto-org-admin // grant. A guard that only saw user writes would leave a memo standing // across exactly the grants that matter. See {@link permissionSetMemo}. - if (opCtx.operation === 'insert' || opCtx.operation === 'update' || opCtx.operation === 'delete') { - this.writeEpoch++; + // + // [#11968] When the engine owns the seam it has ALREADY bumped, earlier + // in this same operation and ahead of every middleware — bumping again + // here would be a second advance for one write. Harmless to the memo + // (which only compares equality) but not harmless on the bus: the + // `authz.invalidated` bridge publishes per bump. The guard below is + // therefore about message count, not about correctness. + if ( + !this.epoch.seamOwnedByEngine && + (opCtx.operation === 'insert' || opCtx.operation === 'update' || opCtx.operation === 'delete') + ) { + this.epoch.bump('write'); } // System operations bypass security @@ -4447,11 +4485,11 @@ export class SecurityPlugin implements Plugin { // and no caller should have to know which of the two it holds. (The // PermissionSet objects inside were already shared instances: they come // from the metadata/bootstrap registries, not from this call.) - if (hit && hit.epoch === this.writeEpoch && hit.key === key) { + if (hit && hit.epoch === this.epoch.current && hit.key === key) { return hit.sets.then((s) => [...s]); } const sets = this.resolvePermissionSetsForContextUnmemoized(context); - const entry = { epoch: this.writeEpoch, key, sets }; + const entry = { epoch: this.epoch.current, key, sets }; this.permissionSetMemo.set(context, entry); sets.catch(() => { if (this.permissionSetMemo.get(context) === entry) { diff --git a/packages/plugins/plugin-security/src/write-epoch-source.ts b/packages/plugins/plugin-security/src/write-epoch-source.ts new file mode 100644 index 0000000000..f6b1cdfcd9 --- /dev/null +++ b/packages/plugins/plugin-security/src/write-epoch-source.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AuthzInvalidationReason } from '@objectstack/core'; + +/** + * ── Where this plugin's invalidation epoch comes from (#11968) ────────────── + * + * The counter behind {@link SecurityPlugin.permissionSetMemo} used to be a + * private field on the plugin (#10757). #11633 §10.3 hoists that mechanism into + * the engine — `ObjectQL.writeEpoch` — so a second consumer shares ONE signal + * instead of minting a parallel one that observes a different set of writes. + * This module is the plugin's side of that extraction. + * + * ⚠️ Duck-typed on purpose. `@objectstack/plugin-security` does not depend on + * `@objectstack/objectql` (it reaches the engine through the `objectql` + * SERVICE), so the seam is feature-detected exactly the way + * `MetadataClusterBridgePlugin` feature-detects `attachClusterPubSub()`. + * + * The fallback is not decoration: the plugin is routinely started against test + * doubles and embeddings whose engine is not `ObjectQL`. Those keep the old + * behaviour — a local counter advanced by this plugin's own middleware — + * because the memo's correctness must not depend on which engine is wired. + */ + +/** The epoch surface this plugin consumes, whichever side supplies it. */ +export interface WriteEpochSource { + /** Monotonic counter; a memo entry is live only while this has not moved. */ + readonly current: number; + /** Advance it. A no-op is never acceptable here — see the fallback below. */ + bump(reason: AuthzInvalidationReason): void; + /** + * True when the **engine** advances this on every write passing its + * middleware seam. When true this plugin must NOT bump on writes itself: the + * engine already did, earlier in the same operation. When false the plugin's + * middleware is the only thing that will, so it must. + */ + readonly seamOwnedByEngine: boolean; +} + +/** A private counter, for an engine that does not expose the seam. */ +export function localWriteEpochSource(): WriteEpochSource { + let epoch = 0; + return { + get current() { + return epoch; + }, + bump() { + epoch += 1; + }, + seamOwnedByEngine: false, + }; +} + +/** + * Prefer the engine's seam epoch; fall back to a local counter. + * + * The engine's is preferred because its bump is strictly EARLIER and strictly + * WIDER-by-construction: it runs ahead of the whole middleware chain rather + * than at the head of this one plugin's middleware, and it covers exactly the + * operations the chain sees, with no second author to keep in step. + */ +export function resolveWriteEpochSource(engine: unknown): WriteEpochSource { + const candidate = (engine as { writeEpoch?: unknown } | null | undefined) + ?.writeEpoch as + | { current?: unknown; bump?: unknown; subscribe?: unknown } + | undefined; + + if ( + candidate && + typeof candidate === 'object' && + typeof candidate.current === 'number' && + typeof candidate.bump === 'function' && + typeof candidate.subscribe === 'function' + ) { + const seam = candidate as unknown as { + readonly current: number; + bump(reason: AuthzInvalidationReason): number; + }; + return { + get current() { + return seam.current; + }, + bump(reason) { + seam.bump(reason); + }, + seamOwnedByEngine: true, + }; + } + + return localWriteEpochSource(); +} diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index e41ce2b97c..41da88e631 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -2,6 +2,7 @@ import { ObjectKernel, Plugin, IHttpServer, ObjectKernelConfig } from '@objectstack/core'; import { + AuthzClusterBridgePlugin, ClusterServicePlugin, MetadataClusterBridgePlugin, type ClusterServicePluginOptions, @@ -71,6 +72,18 @@ export class Runtime { // it's registered by a plugin or directly. this.kernel.use(new MetadataClusterBridgePlugin()); } + + // [#11968] The authorization-cache substrate's bridge + boot-time + // posture statement. Registered UNCONDITIONALLY, outside the + // `cluster !== false` branch above, and that placement is the point: + // `cluster: false` is not a reason to skip the check, it is the loudest + // case the check has — an enabled grants cache with no invalidation bus + // whatsoever. Skipping it there would reproduce #4785's shape, a + // security-relevant mechanism absent with nothing said. + // + // Inert on the shipped default: with OS_AUTHZ_GRANTS_CACHE_TTL_MS at 0 + // it attaches nothing, publishes nothing and logs nothing above debug. + this.kernel.use(new AuthzClusterBridgePlugin()); } private normalizeClusterOptions( diff --git a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts new file mode 100644 index 0000000000..0d325558e0 --- /dev/null +++ b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + readAuthzGrantsCacheTtlMs, + reportAuthzCachePosture, + AUTHZ_GRANTS_CACHE_TTL_ENV, + type AuthzInvalidationBusState, +} from '@objectstack/core'; +import type { IClusterService } from '@objectstack/spec/contracts'; +import { isInProcessClusterDriver } from './split-brain-guard.js'; + +/** + * ── `authz.invalidated` bridge + the boot-time posture statement (#11968) ──── + * + * Two jobs, and the second is the reason this plugin belongs in the DEFAULT + * composition rather than being something an app remembers to add: + * + * 1. **Bridge** the engine's write epoch onto the `authz.invalidated` cluster + * channel, so a grant change on one replica reaches its peers in one + * network hop instead of one TTL. Shaped after + * {@link ../metadata-cluster-bridge-plugin.js MetadataClusterBridgePlugin}: + * late-binds at `kernel:ready`, duck-types the engine service, and does + * nothing when what it needs is absent. + * + * 2. ⭐ **State the posture out loud** when a grants cache is enabled and + * there is no such bridge. Non-optional by the 2026-08-25 ruling on #11633 + * (Fork 2 → B). A silently-absent invalidation bridge is #4785's failure + * shape — a security control disabled by configuration with nothing said — + * and job 2 exists so that cannot recur here. + * + * ## Why it registers even when there is no cluster service + * + * The metadata bridge may return at `debug` when no `cluster` service exists, + * because a missed `metadata.changed` costs a stale schema and loses no data. + * The equivalent silence here would cost a permission honoured past its + * revocation. So this plugin runs its posture check FIRST and unconditionally: + * "no cluster service at all" is not a reason to say nothing, it is the loudest + * case there is. + * + * ## Why it is inert until a cache is enabled + * + * With {@link AUTHZ_GRANTS_CACHE_TTL_ENV} at its default `0` — the shipped + * default, ruled at Fork 4 — nothing caches authorization answers, so there is + * nothing to invalidate and nothing to state. The plugin attaches no bridge, + * publishes no message and logs no posture line. That is the substrate's + * acceptance criterion: with no cache consumers, runtime behaviour is + * unchanged. + */ +export class AuthzClusterBridgePlugin implements Plugin { + name = 'com.objectstack.service.authz-cluster-bridge'; + version = '1.0.0'; + type = 'standard'; + + private detach?: () => void; + + async init(ctx: PluginContext): Promise { + ctx.hook('kernel:ready', async () => { + const ttl = readAuthzGrantsCacheTtlMs(); + + // ── The silent arm ────────────────────────────────────────────── + // No cache enabled ⇒ no staleness window exists ⇒ nothing to bridge + // and nothing to state. A courtesy line on every default boot would + // train operators to skim past the one that matters. + if (ttl.ttlMs <= 0 && !ttl.malformed) { + ctx.logger.debug( + 'AuthzClusterBridgePlugin: grants cache disabled ' + + `(${AUTHZ_GRANTS_CACHE_TTL_ENV}=0); no bridge, no posture line`, + ); + return; + } + + const cluster = this.resolveCluster(ctx); + const engine = this.resolveEngine(ctx); + + let bus: AuthzInvalidationBusState = 'absent'; + if (cluster && engine) { + if (isInProcessClusterDriver(cluster.driver)) { + // A cluster service IS registered — `Runtime` registers the + // memory driver by default — but it fans out to nobody. + // Reporting this as "bridged" is the exact misreading the + // posture statement exists to prevent. + bus = 'in-process'; + } else { + try { + this.detach = engine.attachAuthzInvalidationPubSub( + cluster.pubsub, + cluster.nodeId, + ); + bus = 'bridged'; + } catch (err) { + ctx.logger.error( + 'AuthzClusterBridgePlugin: attach failed', + err as Error, + ); + bus = 'absent'; + } + } + } + + // ── The loud arm ──────────────────────────────────────────────── + reportAuthzCachePosture( + { + ttlMs: ttl.ttlMs, + bus, + ...(cluster ? { driver: cluster.driver } : {}), + ...(ttl.malformed ? { malformedTtl: { raw: ttl.raw } } : {}), + }, + ctx.logger, + ); + }); + + ctx.hook('kernel:shutdown', async () => { + try { + this.detach?.(); + } catch (err) { + ctx.logger.error( + 'AuthzClusterBridgePlugin: detach error', + err as Error, + ); + } + this.detach = undefined; + }); + } + + /** The `cluster` service, or undefined when none is registered. */ + private resolveCluster(ctx: PluginContext): IClusterService | undefined { + try { + return ctx.getService('cluster'); + } catch { + return undefined; + } + } + + /** + * The engine, if it exposes the substrate seam. Duck-typed exactly like + * `MetadataClusterBridgePlugin` feature-detects `attachClusterPubSub()`: + * this package must not depend on `@objectstack/objectql`. + */ + private resolveEngine( + ctx: PluginContext, + ): + | { + attachAuthzInvalidationPubSub: ( + pubsub: IClusterService['pubsub'], + nodeId: string, + ) => () => void; + } + | undefined { + let svc: unknown; + try { + svc = ctx.getService('objectql'); + } catch { + return undefined; + } + const attach = (svc as { attachAuthzInvalidationPubSub?: unknown }) + ?.attachAuthzInvalidationPubSub; + if (typeof attach !== 'function') return undefined; + return { + attachAuthzInvalidationPubSub: (pubsub, nodeId) => + (attach as (p: unknown, n: string) => () => void).call( + svc, + pubsub, + nodeId, + ), + }; + } +} diff --git a/packages/services/service-cluster/src/index.ts b/packages/services/service-cluster/src/index.ts index 6de01c3936..1b2c44eb28 100644 --- a/packages/services/service-cluster/src/index.ts +++ b/packages/services/service-cluster/src/index.ts @@ -42,11 +42,17 @@ export { export { assertClusterDriverSafeForTopology, declaresMultiNode, + isInProcessClusterDriver, type SplitBrainGuardEnv, } from './split-brain-guard.js'; export { MetadataClusterBridgePlugin } from './metadata-cluster-bridge-plugin.js'; +// [#11968] The `authz.invalidated` bridge + the non-optional boot-time posture +// statement (#11633 §3, ruled 2026-08-25). Registered by `@objectstack/runtime` +// in the default composition; inert until a grants cache is enabled. +export { AuthzClusterBridgePlugin } from './authz-cluster-bridge-plugin.js'; + // Re-export contracts for convenience. export type { IClusterService, diff --git a/packages/services/service-cluster/src/split-brain-guard.ts b/packages/services/service-cluster/src/split-brain-guard.ts index 8011a9ebf5..d15526cac6 100644 --- a/packages/services/service-cluster/src/split-brain-guard.ts +++ b/packages/services/service-cluster/src/split-brain-guard.ts @@ -26,6 +26,23 @@ /** In-process drivers whose state does not coordinate across replicas. */ const IN_PROCESS_DRIVERS = new Set(['memory']); +/** + * True when `driver` keeps its cluster primitives **inside one process**, so + * `IPubSub` does not fan out to peer replicas. + * + * Exported (#11968) so the authorization-cache posture statement reads this + * fact from the module that owns it rather than re-deciding which drivers are + * in-process. The two consumers ask it for different reasons and must not be + * allowed to drift: this guard throws when a multi-node topology is *declared* + * over an in-process driver, while the posture statement warns when an enabled + * grants cache has no cross-node invalidation channel — a case that needs no + * declaration to be real, because `Runtime` registers the memory driver by + * default. + */ +export function isInProcessClusterDriver(driver: string): boolean { + return IN_PROCESS_DRIVERS.has(driver); +} + /** Environment inputs the guard reads. */ export interface SplitBrainGuardEnv { /** `'true'` -> operator declares a multi-node deployment. */ @@ -63,7 +80,7 @@ export function assertClusterDriverSafeForTopology( env: SplitBrainGuardEnv = process.env, ): void { if (!declaresMultiNode(env)) return; - if (!IN_PROCESS_DRIVERS.has(driver)) return; + if (!isInProcessClusterDriver(driver)) return; if (isTrue(env.OS_ALLOW_MEMORY_CLUSTER_MULTINODE)) return; throw new Error( From dbca924f20f2ce776406fc8ccfa644cd285daf71 Mon Sep 17 00:00:00 2001 From: os-warren Date: Thu, 27 Aug 2026 02:15:38 +0000 Subject: [PATCH 2/4] test(authz-substrate): pin both arms of the posture statement, the engine seam and the lost-hint contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification the recovered WIP commit did not carry. Three new test files and one existing pin updated in a deliberate direction: - `write-epoch.test.ts` — the seam covers the three write verbs and no read verb, advances even when a middleware refuses the write, advances for an object no middleware is registered for (the "seam with holes" regression), and — the card's own acceptance criterion — a fresh engine has ZERO epoch subscribers, so the substrate publishes nothing while there are no consumers. - `authz-invalidation-bridge.test.ts` — a lost hint costs latency, never correctness: a rejecting publish, a synchronously throwing publish and a missing logger all leave the epoch already advanced and the write untouched. Loopback suppression and the no-echo-of-remote rule are pinned too. - `authz-cluster-bridge-plugin.test.ts` — the posture statement where it actually happens. Loud arm: no cluster service, an in-process driver, a remote driver with no engine seam, a failed attach, a malformed TTL. Silent arm: the shipped default attaches nothing and says nothing above debug. - `runtime.test.ts` — `cluster: false` now registers exactly one plugin, the authz posture bridge, where it previously registered none. The direction is the point: a missing bus is the loudest case the posture check has. Plus the changeset and the `OS_AUTHZ_GRANTS_CACHE_TTL_MS` row in the canonical environment-variable table, stated honestly as a knob no cache consumes yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../authz-cache-invalidation-substrate.md | 59 ++++ .../docs/deployment/environment-variables.mdx | 1 + .../src/authz-invalidation-bridge.test.ts | 247 ++++++++++++++ packages/objectql/src/write-epoch.test.ts | 318 ++++++++++++++++++ packages/runtime/src/runtime.test.ts | 26 +- .../src/authz-cluster-bridge-plugin.test.ts | 215 ++++++++++++ 6 files changed, 862 insertions(+), 4 deletions(-) create mode 100644 .changeset/authz-cache-invalidation-substrate.md create mode 100644 packages/objectql/src/authz-invalidation-bridge.test.ts create mode 100644 packages/objectql/src/write-epoch.test.ts create mode 100644 packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts diff --git a/.changeset/authz-cache-invalidation-substrate.md b/.changeset/authz-cache-invalidation-substrate.md new file mode 100644 index 0000000000..0006b28ecc --- /dev/null +++ b/.changeset/authz-cache-invalidation-substrate.md @@ -0,0 +1,59 @@ +--- +"@objectstack/objectql": minor +"@objectstack/core": minor +"@objectstack/service-cluster": minor +"@objectstack/runtime": minor +"@objectstack/plugin-security": patch +--- + +feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968) + +The substrate step (§10.3) of the accepted #11633 cross-request caching design +(maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation +machinery once, before the grants cache (#11967) that will consume it, so that +leg does not carry it. **Nothing here caches anything.** + +- **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine + middleware seam on every `insert` / `update` / `delete`, ahead of the whole + chain (and so ahead of any `isSystem` bypass a middleware applies). It + generalises the private counter `@objectstack/plugin-security` has carried + since #10757: the mechanism was always the engine's, and hoisting it lets a + second consumer share **one** signal instead of minting a parallel one that + watches a different set of writes. A seam rather than a list of call sites, + because a forgotten call site fails as silent over-permission and writing + through the engine is the only way to write at all — including better-auth's + own adapter. +- **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged + in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a + consuming cache carries is the correctness contract; this channel is not.** No + shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a + missed message is *expected*, the bridge stays out of the write path (a + publish failure is logged and swallowed, never awaited by the writer), and the + channel only moves the *typical* convergence from one TTL to one network hop. + That statement lives in the code at the channel, where a consumer reads it. +- **The boot-time posture statement** — non-optional by the ruling. Whenever a + grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no + cross-node invalidation bus, the deployment is told so at `warn`, every boot, + naming the window it accepted and the remedy. It is a statement, not a + refusal: a TTL-bounded per-process cache is a legitimate configuration. It is + said out loud because a silently-absent invalidation bridge is how a security + control gets disabled with nobody noticing (#4785). The in-process `memory` + driver counts as **no** bus — a cluster service exists on the shipped default + while fanning out to nobody, which is the case a "is a cluster service + registered?" check answers `yes` to and is wrong about. + +**Runtime behaviour is unchanged.** With no cache consumer the epoch has zero +subscribers, so nothing is published and nothing is invalidated; with the +shipped default TTL of `0` the bridge attaches nothing and logs nothing above +`debug`. The one composition change worth naming: `Runtime` now registers +`AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false` +— that is not an oversight, it is the loudest case the posture check has, and +skipping it there would put the statement's absence exactly where the missing +bus is. + +`@objectstack/plugin-security` is a `patch`: its permission-set memo now reads +the engine's epoch when the wired engine exposes one and keeps its private +counter otherwise (test doubles, embeddings). The covered set of writes is +identical — the plugin's own middleware was already global — and it is now +identical *by construction* rather than by two files agreeing on which +operations count. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 08d814e15b..e3e6d4971a 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -65,6 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. | | `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). | | `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). | +| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (ADR-0127 / #11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | --- diff --git a/packages/objectql/src/authz-invalidation-bridge.test.ts b/packages/objectql/src/authz-invalidation-bridge.test.ts new file mode 100644 index 0000000000..1227a4da28 --- /dev/null +++ b/packages/objectql/src/authz-invalidation-bridge.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11968] The `authz.invalidated` bridge — the cross-node half of the ruled + * #11633 substrate (§3, Fork 2 → B, accepted 2026-08-25). + * + * ⭐ The property under test is NOT "the message arrives". It is that **a lost + * message costs nothing but latency**: no shipped driver delivers better than + * at-most-once (`cluster.mdx` §4.2), so the bridge must stay out of the write + * path entirely — a publish that rejects, throws, or never happens leaves the + * local epoch already advanced and the write already done. A test suite that + * only asserted delivery would pass on a bridge that awaited the network inside + * a grant revocation, which is the failure this file exists to forbid. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { IPubSub, PubSubHandler, PubSubMessage } from '@objectstack/spec/contracts'; +import { AUTHZ_INVALIDATED_CHANNEL, type AuthzInvalidatedPayload } from '@objectstack/core'; +import { WriteEpoch } from './write-epoch.js'; +import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; + +/** An in-memory `IPubSub` double that records what was published. */ +function makeBus(publishImpl?: (channel: string, payload: unknown) => Promise) { + const handlers = new Map>>(); + const published: Array<{ channel: string; payload: any }> = []; + let unsubscribeCalls = 0; + + const bus: IPubSub = { + async publish(channel: string, payload: T): Promise { + published.push({ channel, payload }); + if (publishImpl) return publishImpl(channel, payload); + }, + subscribe(channel: string, handler: PubSubHandler) { + let set = handlers.get(channel); + if (!set) { + set = new Set(); + handlers.set(channel, set); + } + set.add(handler as PubSubHandler); + return () => { + unsubscribeCalls += 1; + set!.delete(handler as PubSubHandler); + }; + }, + async close(): Promise {}, + }; + + /** Deliver a message as a peer would. */ + const deliver = (payload: AuthzInvalidatedPayload, fromNode?: string) => { + const msg: PubSubMessage = { + channel: AUTHZ_INVALIDATED_CHANNEL, + payload, + publishedAt: Date.now(), + ...(fromNode ? { fromNode } : {}), + }; + for (const h of handlers.get(AUTHZ_INVALIDATED_CHANNEL) ?? []) h(msg); + }; + + return { + bus, + published, + deliver, + subscriberCount: () => (handlers.get(AUTHZ_INVALIDATED_CHANNEL) ?? new Set()).size, + unsubscribeCalls: () => unsubscribeCalls, + }; +} + +describe('[#11968] outbound — a local bump becomes one hint', () => { + it('publishes on the authz.invalidated channel, stamped with this node', () => { + const { bus, published } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + epoch.bump('write'); + + expect(published).toHaveLength(1); + expect(published[0].channel).toBe(AUTHZ_INVALIDATED_CHANNEL); + expect(published[0].payload).toMatchObject({ + originNode: 'node-a', + epoch: 1, + reason: 'write', + }); + expect(typeof published[0].payload.at).toBe('number'); + }); + + it('a metadata bump is published too — a declared permission set writes no row', () => { + const { bus, published } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + epoch.bump('metadata'); + + expect(published).toHaveLength(1); + expect(published[0].payload.reason).toBe('metadata'); + }); +}); + +describe('[#11968] inbound — a peer hint advances the local epoch', () => { + it('a hint from another node bumps locally, exactly once', () => { + const { bus, deliver } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + deliver({ originNode: 'node-b', epoch: 42, reason: 'write', at: Date.now() }); + + expect(epoch.current).toBe(1); + }); + + it('the local epoch does NOT adopt the peer value — the counters are independent', () => { + // Adopting a peer's number would make the counter non-monotonic on this + // node the first time a peer restarted, and the memo key it feeds compares + // equality. "Something changed" is the whole payload; 42 is diagnostic. + const { bus, deliver } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + deliver({ originNode: 'node-b', epoch: 9999, reason: 'write', at: Date.now() }); + + expect(epoch.current).toBe(1); + }); + + it('ignores its OWN hint — loopback would double-count every write', () => { + const { bus, deliver } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + deliver({ originNode: 'node-a', epoch: 1, reason: 'write', at: Date.now() }); + + expect(epoch.current).toBe(0); + }); + + it('a remote-caused bump is NOT re-published — two nodes would trade one write forever', () => { + const { bus, published, deliver } = makeBus(); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + deliver({ originNode: 'node-b', epoch: 1, reason: 'write', at: Date.now() }); + + expect(epoch.current).toBe(1); + expect(published).toHaveLength(0); + }); +}); + +describe('[#11968] ⭐ a lost hint costs latency, never correctness', () => { + it('a rejecting publish is swallowed; the epoch already advanced', async () => { + const debug = vi.fn(); + const { bus } = makeBus(async () => { + throw new Error('redis is down'); + }); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a', logger: { debug } }); + + expect(() => epoch.bump('write')).not.toThrow(); + expect(epoch.current).toBe(1); + + // A macrotask turn, not a fixed number of microtask ticks: the rejection + // travels through an async function's ADOPTION of the driver's promise + // before the bridge's own `.catch` runs, and counting those ticks is how a + // timing-fragile assertion gets written. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(debug).toHaveBeenCalledTimes(1); + expect(debug.mock.calls[0][0]).toMatch(/TTL bounds it/); + }); + + it('a SYNCHRONOUSLY throwing publish is swallowed too', () => { + const debug = vi.fn(); + const handlers = new Map>>(); + const bus: IPubSub = { + publish(): Promise { + throw new Error('driver threw before returning a promise'); + }, + subscribe(channel: string, handler: PubSubHandler) { + let set = handlers.get(channel); + if (!set) { + set = new Set(); + handlers.set(channel, set); + } + set.add(handler as PubSubHandler); + return () => set!.delete(handler as PubSubHandler); + }, + async close(): Promise {}, + }; + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a', logger: { debug } }); + + expect(() => epoch.bump('write')).not.toThrow(); + expect(epoch.current).toBe(1); + expect(debug).toHaveBeenCalledTimes(1); + }); + + it('with no logger at all, a failing publish is still not an error', async () => { + const { bus } = makeBus(async () => { + throw new Error('redis is down'); + }); + const epoch = new WriteEpoch(); + bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + expect(() => epoch.bump('write')).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(epoch.current).toBe(1); + }); +}); + +describe('[#11968] teardown', () => { + it('the disposer unsubscribes both directions, and is idempotent', () => { + const bus = makeBus(); + const epoch = new WriteEpoch(); + const detach = bridgeAuthzInvalidation({ + epoch, + pubsub: bus.bus, + nodeId: 'node-a', + }); + + expect(bus.subscriberCount()).toBe(1); + expect(epoch.listenerCount).toBe(1); + + detach(); + detach(); + + expect(bus.subscriberCount()).toBe(0); + expect(epoch.listenerCount).toBe(0); + expect(bus.unsubscribeCalls()).toBe(1); + + // And nothing is published after detach. + epoch.bump('write'); + expect(bus.published).toHaveLength(0); + }); + + it('a driver whose unsubscribe throws does not strand the local listener', () => { + const handlers = new Set>(); + const bus: IPubSub = { + async publish(): Promise {}, + subscribe(_channel: string, handler: PubSubHandler) { + handlers.add(handler as PubSubHandler); + return () => { + throw new Error('driver unsubscribe exploded'); + }; + }, + async close(): Promise {}, + }; + const epoch = new WriteEpoch(); + const detach = bridgeAuthzInvalidation({ epoch, pubsub: bus, nodeId: 'node-a' }); + + expect(() => detach()).not.toThrow(); + expect(epoch.listenerCount).toBe(0); + }); +}); diff --git a/packages/objectql/src/write-epoch.test.ts b/packages/objectql/src/write-epoch.test.ts new file mode 100644 index 0000000000..dbcdf912c1 --- /dev/null +++ b/packages/objectql/src/write-epoch.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11968] The engine-seam write epoch — the invalidation substrate of the + * ruled #11633 caching design (§2.1, §3; maintainer acceptance 2026-08-25). + * + * Two things are pinned here, and the second is the half that a substrate card + * most easily ships without: + * + * 1. **The seam covers the writes and only the writes.** `insert` / `update` / + * `delete` advance the counter; every read verb leaves it alone. The + * covered set is the one `executeWithMiddleware` sees, which is what makes + * it un-forgettable rather than a list somebody maintains. + * 2. ⭐ **Runtime behaviour is unchanged while there are no consumers.** That + * is the card's own acceptance criterion, and it is asserted rather than + * described: a freshly-initialised engine has ZERO epoch subscribers and no + * `authz.invalidated` binding, so the substrate publishes nothing, drops + * nothing and cannot change a query multiset. A counter nobody reads is the + * whole of the observable difference this card lands. + * + * ⚠️ The bump is asserted through the ENGINE, not by calling `WriteEpoch` + * directly, for the same reason #11633 §2.1 chose a seam over a call-site list: + * the claim is about where the counter is wired, and a unit test of the counter + * cannot fail when the wiring moves. The unit-level cases below cover the + * counter's own contract only, and are labelled as such. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { + WriteEpoch, + WRITE_EPOCH_OPERATIONS, + isWriteEpochLike, + isWriteEpochOperation, +} from './write-epoch.js'; + +const epochObject = { + name: 'epoch_task', + label: 'Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + }, +}; + +const otherObject = { + name: 'epoch_other', + label: 'Other', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + }, +}; + +/** + * A minimal store-backed driver. Its WHERE matcher **refuses** anything it does + * not implement instead of skipping it: a `$`-prefixed combinator read as a + * field name is the silently-wrong shape hand-written matchers keep landing in, + * and this file has no need of one — every predicate below is `{ id }`. + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { + s = new Map(); + stores.set(obj, s); + } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: unknown): boolean => { + if (where === undefined || where === null) return true; + if (typeof where !== 'object') { + throw new Error(`epoch stub driver: unsupported WHERE ${String(where)}`); + } + for (const [k, v] of Object.entries(where as Record)) { + if (k.startsWith('$')) { + throw new Error(`epoch stub driver: combinator "${k}" is not supported`); + } + if (v !== null && typeof v === 'object') { + throw new Error(`epoch stub driver: operator object on "${k}" is not supported`); + } + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { + return true; + }, + async execute() { + return null; + }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return next; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { + return storeFor(object).delete(id); + }, + async count(object: string, ast: any) { + return (await this.find(object, ast)).length; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { + return []; + }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + for (const r of rows) storeFor(object).set(r.id as string, { ...r, ...data, id: r.id }); + return rows.length; + }, + async deleteMany(object: string, ast: any) { + const rows = await this.find(object, ast); + for (const r of rows) storeFor(object).delete(r.id as string); + return rows.length; + }, + async beginTransaction() { + return { commit: async () => {}, rollback: async () => {} }; + }, + async commit() {}, + async rollback() {}, + }; + return driver; +} + +describe('[#11968] the engine seam advances the write epoch', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(makeStubDriver(), true); + await engine.init(); + engine.registry.registerObject(epochObject); + engine.registry.registerObject(otherObject); + }); + + it('starts at zero and exposes the substrate surface', () => { + expect(engine.writeEpoch.current).toBe(0); + expect(isWriteEpochLike(engine.writeEpoch)).toBe(true); + }); + + it('insert, update and delete each advance it exactly once', async () => { + const row: any = await engine.insert('epoch_task', { title: 'a' }); + expect(engine.writeEpoch.current).toBe(1); + + await engine.update('epoch_task', { title: 'b' }, { where: { id: row.id } } as any); + expect(engine.writeEpoch.current).toBe(2); + + await engine.delete('epoch_task', { where: { id: row.id } } as any); + expect(engine.writeEpoch.current).toBe(3); + }); + + it('no read verb advances it — a cache that never hits is not a tightening', async () => { + const row: any = await engine.insert('epoch_task', { title: 'a' }); + const afterWrite = engine.writeEpoch.current; + + await engine.find('epoch_task', {} as any); + await engine.findOne('epoch_task', { where: { id: row.id } } as any); + await engine.count('epoch_task', {} as any); + + expect(engine.writeEpoch.current).toBe(afterWrite); + }); + + it('advances even when a middleware REFUSES the write', async () => { + // The bump is ahead of the whole chain on purpose: a refusal is still a + // point at which the answer to "may this caller do that" may have moved, + // and `plugin-security`'s own bump sat ahead of its `isSystem` bypass for + // the same reason. An epoch that only advanced on writes that SUCCEEDED + // would be a seam with a hole exactly where a permission check lives. + engine.registerMiddleware(async () => { + throw new Error('refused by middleware'); + }); + + await expect(engine.insert('epoch_task', { title: 'a' })).rejects.toThrow(/refused/); + expect(engine.writeEpoch.current).toBe(1); + }); + + it('advances for an object no middleware is registered for', async () => { + // ⛔ The regression this pins: moving the bump below the `applicable` + // filter. That filter selects middleware BY OBJECT, so an epoch computed + // after it would advance only when somebody happened to register a + // middleware for the written object — un-missable turned into + // conditionally-missing, with every existing test still green. + engine.registerMiddleware( + async (_ctx: any, next: () => Promise) => { + await next(); + }, + { object: 'epoch_other' }, + ); + + await engine.insert('epoch_task', { title: 'a' }); + expect(engine.writeEpoch.current).toBe(1); + }); +}); + +describe('[#11968] ⭐ with no consumers, the substrate is inert', () => { + // The acceptance criterion of this card, as an assertion rather than a + // sentence: substrate landed, no cache built, runtime behaviour unchanged. + it('a fresh engine has no epoch subscriber and no cluster binding', async () => { + const engine = new ObjectQL(); + engine.registerDriver(makeStubDriver(), true); + await engine.init(); + engine.registry.registerObject(epochObject); + + expect(engine.writeEpoch.listenerCount).toBe(0); + + await engine.insert('epoch_task', { title: 'a' }); + + // The counter moved; nothing observed it, so nothing was published and + // nothing was invalidated. That is the entire observable delta of this card. + expect(engine.writeEpoch.current).toBe(1); + expect(engine.writeEpoch.listenerCount).toBe(0); + }); + + it('detaching a bridge that was never attached is a no-op', () => { + const engine = new ObjectQL(); + expect(() => engine.detachAuthzInvalidationPubSub()).not.toThrow(); + }); +}); + +describe('[#11968] the counter contract (unit level)', () => { + it('is monotonic and reports the new value', () => { + const epoch = new WriteEpoch(); + expect(epoch.current).toBe(0); + expect(epoch.bump('write')).toBe(1); + expect(epoch.bump('metadata')).toBe(2); + expect(epoch.current).toBe(2); + }); + + it('notifies subscribers with the POST-bump value', () => { + const epoch = new WriteEpoch(); + const seen: Array<[number, string]> = []; + epoch.subscribe((n, reason) => seen.push([n, reason])); + epoch.bump('write'); + epoch.bump('remote'); + expect(seen).toEqual([ + [1, 'write'], + [2, 'remote'], + ]); + }); + + it('a throwing subscriber does not fail the write, and the epoch still moved', () => { + const epoch = new WriteEpoch(); + const other: number[] = []; + epoch.subscribe(() => { + throw new Error('bridge exploded'); + }); + epoch.subscribe((n) => other.push(n)); + + expect(() => epoch.bump('write')).not.toThrow(); + expect(epoch.current).toBe(1); + // The second subscriber still ran — one bad listener does not strand the + // rest, which is what makes over-invalidation the worst case here. + expect(other).toEqual([1]); + }); + + it('unsubscribe is idempotent and really stops delivery', () => { + const epoch = new WriteEpoch(); + const seen: number[] = []; + const off = epoch.subscribe((n) => seen.push(n)); + epoch.bump('write'); + off(); + off(); + epoch.bump('write'); + expect(seen).toEqual([1]); + expect(epoch.listenerCount).toBe(0); + }); + + it('the counted operations are the three write verbs, and nothing else', () => { + expect([...WRITE_EPOCH_OPERATIONS].sort()).toEqual(['delete', 'insert', 'update']); + for (const op of ['insert', 'update', 'delete']) { + expect(isWriteEpochOperation(op)).toBe(true); + } + for (const op of ['find', 'findOne', 'count', 'aggregate', '', undefined, null, 7]) { + expect(isWriteEpochOperation(op)).toBe(false); + } + }); + + it('isWriteEpochLike refuses a partial shape', () => { + expect(isWriteEpochLike(null)).toBe(false); + expect(isWriteEpochLike({ current: 0 })).toBe(false); + expect(isWriteEpochLike({ current: 0, bump: () => 1 })).toBe(false); + expect(isWriteEpochLike({ current: 0, bump: () => 1, subscribe: () => () => {} })).toBe(true); + }); +}); diff --git a/packages/runtime/src/runtime.test.ts b/packages/runtime/src/runtime.test.ts index e463805df6..677cb73dce 100644 --- a/packages/runtime/src/runtime.test.ts +++ b/packages/runtime/src/runtime.test.ts @@ -24,19 +24,37 @@ describe('Runtime', () => { expect(runtime.getKernel()).toBeDefined(); }); - it('auto-registers the cluster service plugin and metadata bridge', () => { + it('auto-registers the cluster service plugin, metadata bridge and authz posture bridge', () => { const runtime = new Runtime(); const kernel = runtime.getKernel(); - expect(kernel.use).toHaveBeenCalledTimes(2); + expect(kernel.use).toHaveBeenCalledTimes(3); const names = (kernel.use as any).mock.calls.map((c: any[]) => c[0].name); expect(names).toContain('com.objectstack.service.cluster'); expect(names).toContain('com.objectstack.service.metadata-cluster-bridge'); + // [#11968] The third is the authorization-cache posture bridge. It is + // inert on this default (OS_AUTHZ_GRANTS_CACHE_TTL_MS is 0, so it + // attaches nothing and logs nothing above debug) — the count moves, the + // observable behaviour does not. + expect(names).toContain('com.objectstack.service.authz-cluster-bridge'); }); - it('skips cluster auto-registration when cluster:false', () => { + it('cluster:false skips the CLUSTER plugins — and deliberately keeps the authz posture bridge', () => { + // [#11968] This assertion used to be `not.toHaveBeenCalled()`, and the + // change of direction is the point rather than an accommodation. The + // posture bridge exists to say out loud when a grants cache is enabled + // with no invalidation bus, and `cluster: false` is not a reason to skip + // that check — it is the LOUDEST case the check has. Dropping it here + // would put the statement's absence exactly where the missing bus is, + // which is #4785's shape (a security-relevant mechanism absent, with + // nothing said). Still inert by default: the plugin reads the TTL at + // `kernel:ready` and returns at `debug` when it is 0. const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); - expect(kernel.use).not.toHaveBeenCalled(); + expect(kernel.use).toHaveBeenCalledTimes(1); + const names = (kernel.use as any).mock.calls.map((c: any[]) => c[0].name); + expect(names).toEqual(['com.objectstack.service.authz-cluster-bridge']); + expect(names).not.toContain('com.objectstack.service.cluster'); + expect(names).not.toContain('com.objectstack.service.metadata-cluster-bridge'); }); it('should register external http server if provided', () => { diff --git a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts new file mode 100644 index 0000000000..fd60d4d886 --- /dev/null +++ b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11968] The boot-time posture statement, pinned where it actually happens. + * + * `authz-cache-posture.test.ts` in `@objectstack/core` pins the DECISION — + * given a TTL and a bus state, is the line loud, quiet or absent. This file + * pins the BOOT: which bus state a real composition resolves to, and therefore + * whether the line comes out at all. The two halves are separable and both are + * needed — a perfect decision function reached with the wrong input is silent + * in exactly the deployment the ruling made this non-optional for. + * + * ⭐ Both arms of the card's acceptance criterion are asserted here: + * + * - the line appears **exactly when** a cache flag is on with no cross-node + * bus — including the case that reads as "bus present" and is not: `Runtime` + * registers the MEMORY cluster driver by default, so `getService('cluster')` + * succeeds while nothing crosses a process boundary; + * - and **not otherwise** — with the shipped default (`0`) this plugin + * attaches nothing, publishes nothing, and says nothing above `debug`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { AuthzClusterBridgePlugin } from './authz-cluster-bridge-plugin.js'; + +const TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; + +interface HarnessOptions { + /** Cluster driver name, or `undefined` for "no cluster service registered". */ + driver?: string; + /** When false, `getService('objectql')` throws — no engine seam to attach to. */ + engine?: boolean; + /** When true, the engine's attach method throws. */ + attachThrows?: boolean; +} + +function makeHarness(opts: HarnessOptions = {}) { + const { driver, engine = true, attachThrows = false } = opts; + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const attach = vi.fn(() => { + if (attachThrows) throw new Error('attach exploded'); + return detach; + }); + const detach = vi.fn(); + + const pubsub = { publish: vi.fn(), subscribe: vi.fn(), close: vi.fn() }; + const cluster = + driver === undefined + ? undefined + : { nodeId: 'node-a', driver, pubsub, lock: {}, kv: {}, counter: {}, close: vi.fn() }; + + const hooks = new Map Promise | void>>(); + const ctx = { + logger, + hook(name: string, handler: () => Promise | void) { + const list = hooks.get(name) ?? []; + list.push(handler); + hooks.set(name, list); + }, + getService(name: string) { + if (name === 'cluster') { + if (!cluster) throw new Error('service not found: cluster'); + return cluster; + } + if (name === 'objectql') { + if (!engine) throw new Error('service not found: objectql'); + return { attachAuthzInvalidationPubSub: attach }; + } + throw new Error(`service not found: ${name}`); + }, + } as unknown as PluginContext; + + const fire = async (name: string) => { + for (const h of hooks.get(name) ?? []) await h(); + }; + + return { ctx, logger, attach, detach, fire, pubsub }; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('[#11968] ⭐ the silent arm — the shipped default is inert', () => { + it('says nothing above debug and attaches nothing when the cache is off', async () => { + // The card's own acceptance criterion: substrate landed, no cache + // consumers, runtime behaviour unchanged. A courtesy line on every + // default boot is how the loud line below stops being loud. + vi.stubEnv(TTL_ENV, ''); + const h = makeHarness({ driver: 'memory' }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).not.toHaveBeenCalled(); + expect(h.logger.info).not.toHaveBeenCalled(); + expect(h.attach).not.toHaveBeenCalled(); + }); + + it('an explicit 0 is the same real path, not a degenerate TTL', async () => { + vi.stubEnv(TTL_ENV, '0'); + const h = makeHarness({ driver: 'redis' }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).not.toHaveBeenCalled(); + expect(h.logger.info).not.toHaveBeenCalled(); + expect(h.attach).not.toHaveBeenCalled(); + }); +}); + +describe('[#11968] ⭐ the loud arm — an enabled cache with no bus is stated', () => { + it('warns when NO cluster service is registered at all', async () => { + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: undefined }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).toHaveBeenCalledTimes(1); + expect(h.logger.warn.mock.calls[0][0]).toMatch(/NO .*invalidation bus/); + expect(h.attach).not.toHaveBeenCalled(); + }); + + it('⭐ warns when the cluster service exists but its driver is IN-PROCESS', async () => { + // The case a "is a cluster service registered?" check answers `yes` to + // and is wrong about: `Runtime` registers the memory driver by default, + // so this is the DEFAULT multi-replica deployment, not an exotic one. + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: 'memory' }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).toHaveBeenCalledTimes(1); + expect(h.logger.warn.mock.calls[0][0]).toContain('memory'); + expect(h.attach).not.toHaveBeenCalled(); + }); + + it('warns when a remote driver exists but the engine exposes no seam', async () => { + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: 'redis', engine: false }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).toHaveBeenCalledTimes(1); + expect(h.attach).not.toHaveBeenCalled(); + }); + + it('a FAILED attach is reported as no bus, never as a bridged one', async () => { + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: 'redis', attachThrows: true }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.error).toHaveBeenCalled(); + expect(h.logger.warn).toHaveBeenCalledTimes(1); + }); + + it('a malformed TTL warns on its own — "we read your setting as off" is not inferable', async () => { + vi.stubEnv(TTL_ENV, '5OOO'); + const h = makeHarness({ driver: 'memory' }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.logger.warn).toHaveBeenCalledTimes(1); + expect(h.logger.warn.mock.calls[0][0]).toContain(TTL_ENV); + expect(h.attach).not.toHaveBeenCalled(); + }); +}); + +describe('[#11968] the bridged arm — enabled cache, remote driver', () => { + it('attaches the channel and states the posture at info, not warn', async () => { + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: 'redis' }); + await new AuthzClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + expect(h.attach).toHaveBeenCalledTimes(1); + expect(h.attach.mock.calls[0][1]).toBe('node-a'); + expect(h.logger.warn).not.toHaveBeenCalled(); + expect(h.logger.info).toHaveBeenCalledTimes(1); + expect(h.logger.info.mock.calls[0][0]).toMatch(/TTL remains the correctness bound/); + }); + + it('shutdown detaches what boot attached', async () => { + vi.stubEnv(TTL_ENV, '5000'); + const h = makeHarness({ driver: 'redis' }); + const plugin = new AuthzClusterBridgePlugin(); + await plugin.init(h.ctx); + await h.fire('kernel:ready'); + await h.fire('kernel:shutdown'); + + expect(h.detach).toHaveBeenCalledTimes(1); + + // Idempotent: a second shutdown does not detach twice. + await h.fire('kernel:shutdown'); + expect(h.detach).toHaveBeenCalledTimes(1); + }); + + it('shutdown with nothing attached is a no-op, not a throw', async () => { + vi.stubEnv(TTL_ENV, '0'); + const h = makeHarness({ driver: 'memory' }); + const plugin = new AuthzClusterBridgePlugin(); + await plugin.init(h.ctx); + await h.fire('kernel:ready'); + await expect(h.fire('kernel:shutdown')).resolves.toBeUndefined(); + expect(h.logger.error).not.toHaveBeenCalled(); + }); +}); From 756786cc2e970a800cf58df283fb280e80183521 Mon Sep 17 00:00:00 2001 From: os-warren Date: Thu, 27 Aug 2026 03:03:13 +0000 Subject: [PATCH 3/4] fix(authz-substrate): repair three gate findings the new tests moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `check:objectql-double-limit`: the stub driver's `find` now applies the caller's bound AFTER the filter, by presence. A double that silently drops `limit` answers a different question than the engine asked. - `check:test-source-alias`: `@objectstack/service-cluster` gains a `vitest.config.ts` anchoring `@objectstack/core` to source. The plugin under test resolves the posture decision through that package, and unaliased the workspace link reads `dist/` — a stale build would run the posture tests green against the decision function that used to ship. - `check:query-options-erasure`: the query bags on these calls were already typed by the engine's signatures; the `as any` casts were noise and are gone. And the two ledger drifts the same tests moved, both repaired at the source rather than by raising a shrink-only ratchet: `registerObject` requires an owning package id, and the `attach` mock needed typed parameters for its call tuple to carry the node id the assertion reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- packages/objectql/src/write-epoch.test.ts | 25 ++++++++++------ .../src/authz-cluster-bridge-plugin.test.ts | 4 ++- .../services/service-cluster/vitest.config.ts | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 packages/services/service-cluster/vitest.config.ts diff --git a/packages/objectql/src/write-epoch.test.ts b/packages/objectql/src/write-epoch.test.ts index dbcdf912c1..2b184c2134 100644 --- a/packages/objectql/src/write-epoch.test.ts +++ b/packages/objectql/src/write-epoch.test.ts @@ -34,6 +34,9 @@ import { isWriteEpochOperation, } from './write-epoch.js'; +/** Owning package id — `registerObject` requires one; it is not optional. */ +const WRITE_EPOCH_TEST_PACKAGE = 'os-write-epoch-test'; + const epochObject = { name: 'epoch_task', label: 'Task', @@ -98,7 +101,11 @@ function makeStubDriver() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound is applied AFTER the filter, by PRESENCE — a double + // that silently drops `limit` answers a different question than the + // engine asked, and every assertion built on it reads as evidence. + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; @@ -163,8 +170,8 @@ describe('[#11968] the engine seam advances the write epoch', () => { engine = new ObjectQL(); engine.registerDriver(makeStubDriver(), true); await engine.init(); - engine.registry.registerObject(epochObject); - engine.registry.registerObject(otherObject); + engine.registry.registerObject(epochObject, WRITE_EPOCH_TEST_PACKAGE); + engine.registry.registerObject(otherObject, WRITE_EPOCH_TEST_PACKAGE); }); it('starts at zero and exposes the substrate surface', () => { @@ -176,10 +183,10 @@ describe('[#11968] the engine seam advances the write epoch', () => { const row: any = await engine.insert('epoch_task', { title: 'a' }); expect(engine.writeEpoch.current).toBe(1); - await engine.update('epoch_task', { title: 'b' }, { where: { id: row.id } } as any); + await engine.update('epoch_task', { title: 'b' }, { where: { id: row.id } }); expect(engine.writeEpoch.current).toBe(2); - await engine.delete('epoch_task', { where: { id: row.id } } as any); + await engine.delete('epoch_task', { where: { id: row.id } }); expect(engine.writeEpoch.current).toBe(3); }); @@ -187,9 +194,9 @@ describe('[#11968] the engine seam advances the write epoch', () => { const row: any = await engine.insert('epoch_task', { title: 'a' }); const afterWrite = engine.writeEpoch.current; - await engine.find('epoch_task', {} as any); - await engine.findOne('epoch_task', { where: { id: row.id } } as any); - await engine.count('epoch_task', {} as any); + await engine.find('epoch_task'); + await engine.findOne('epoch_task', { where: { id: row.id } }); + await engine.count('epoch_task'); expect(engine.writeEpoch.current).toBe(afterWrite); }); @@ -233,7 +240,7 @@ describe('[#11968] ⭐ with no consumers, the substrate is inert', () => { const engine = new ObjectQL(); engine.registerDriver(makeStubDriver(), true); await engine.init(); - engine.registry.registerObject(epochObject); + engine.registry.registerObject(epochObject, WRITE_EPOCH_TEST_PACKAGE); expect(engine.writeEpoch.listenerCount).toBe(0); diff --git a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts index fd60d4d886..7dc659bd30 100644 --- a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts +++ b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.test.ts @@ -45,7 +45,9 @@ function makeHarness(opts: HarnessOptions = {}) { error: vi.fn(), }; - const attach = vi.fn(() => { + // Typed parameters, not `() => …`: the call tuple is what the node-id + // assertion below reads, and an untyped mock gives it an empty tuple. + const attach = vi.fn((_pubsub: unknown, _nodeId: string) => { if (attachThrows) throw new Error('attach exploded'); return detach; }); diff --git a/packages/services/service-cluster/vitest.config.ts b/packages/services/service-cluster/vitest.config.ts new file mode 100644 index 0000000000..01c5d06b19 --- /dev/null +++ b/packages/services/service-cluster/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +/** + * [#11968] `authz-cluster-bridge-plugin.ts` reads the grants-cache TTL and + * resolves the boot-time posture statement through `@objectstack/core` + * (`readAuthzGrantsCacheTtlMs`, `reportAuthzCachePosture`), and its test drives + * the plugin end to end. Unaliased, the workspace link resolves that package to + * `dist/`, which makes the verdict a function of build state rather than of the + * source in this checkout — and the dangerous direction is the quiet one: a + * `dist` merely BEHIND runs the posture tests green against the decision + * function that USED to ship. This gate family (`pnpm check:test-source-alias`) + * exists for exactly that reading. + * + * ANCHORED regex, array form, deliberately: a bare string `find` matches by + * PREFIX, so with a FILE replacement it would also swallow any subpath and + * resolve it to `…/core/src/index.ts/` — `ENOTDIR` at run time, from a + * config that reads as correct. Mirrors the identical rule in + * `packages/services/service-messaging/vitest.config.ts`. + */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@objectstack\/core$/, + replacement: path.resolve(__dirname, '../../core/src/index.ts'), + }, + ], + }, +}); From 998986f05c49c4038b1ee3fbe57476c39bad1d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:07:34 +0000 Subject: [PATCH 4/4] fix(docs,core): drop the dangling ADR-0127 citation from both sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:adr-anchors` was red on this branch: ADR-0127 is cited by 2 files but names no record under `docs/adr/` (records top out at 0126). A citation is a promise the decision is readable at the other end, and an unshipped number is also a squat — whoever writes the real ADR-0127 would retroactively falsify both citations at once. Takes the gate's remedy (b), "cite the number that exists": keep `#11633`, which resolves today, and drop the ADR token. In `security/index.ts` the phrase was `ADR-0127-shaped`, so the shape is now named outright — TTL-bounded, invalidated over a best-effort cross-node channel — rather than pointed at. Prose is otherwise unchanged; no behaviour changes. Not remedy (a): `docs/adr/**` is maintainer hand-merge only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- content/docs/deployment/environment-variables.mdx | 2 +- packages/core/src/security/index.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index e3e6d4971a..d56336f68c 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -65,7 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. | | `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). | | `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). | -| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (ADR-0127 / #11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | +| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | --- diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index c32bcb8602..d674a3f39c 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -178,11 +178,12 @@ export { withoutOperationPrivateKeys, } from './operation-private-keys.js'; -// [#11968] ADR-0127-shaped authorization caching SUBSTRATE — the cross-node -// channel contract and the boot-time posture statement (#11633 §3, Fork 2 → B, -// ruled 2026-08-25). No cache lives here and nothing consumes these yet; leg B -// (#11967) is the first consumer. Read the channel module before using either: -// the TTL is the correctness contract, and a missed message is EXPECTED. +// [#11968] Authorization caching SUBSTRATE, TTL-bounded and invalidated over a +// best-effort cross-node channel — the channel contract and the boot-time +// posture statement (#11633 §3, Fork 2 → B, ruled 2026-08-25). No cache lives +// here and nothing consumes these yet; leg B (#11967) is the first consumer. +// Read the channel module before using either: the TTL is the correctness +// contract, and a missed message is EXPECTED. export { AUTHZ_INVALIDATED_CHANNEL, type AuthzInvalidationReason,