From 65c96a61a5b01f946e20ff02f02b4ea3996599c7 Mon Sep 17 00:00:00 2001 From: chris-stafflink <84813115+bellcoTech@users.noreply.github.com> Date: Thu, 14 May 2026 12:34:39 +1000 Subject: [PATCH] fix(context): share AsyncLocalStorage across ESM+CJS variants via globalThis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@cedarjs/context` ships dual ESM+CJS and Node resolves the variant per caller. In a mixed-variant load (e.g. an ESM user-api package alongside cedar tooling that loads `@cedarjs/api-server`'s CJS entry), each variant is a separate module instance with its own module-scoped state. The `AsyncLocalStorage` singleton previously lived in a module-scoped `let`, so each variant had its own store. The framework's request handler would write `context.currentUser` into the variant it loaded while user code reading `context.currentUser` saw the other variant's empty store — a textbook dual-package hazard. Concretely this surfaces as every `requireAuth`-gated query failing with "You do not have permission to do that" even after `getCurrentUser` runs successfully, because the user written to the framework's CJS store is invisible to the user code reading from the ESM store (or vice versa). Stashing the singleton on `globalThis` under a `Symbol.for` key bridges the two variants: both resolve the same key to the same object and so share the store regardless of which variant either loaded. The key is namespaced (`__cedarjs_context_storage__`) to avoid any chance of collision. No behaviour change for single-variant projects — they previously got one module instance and one store, and they still get one module instance and one store, just rooted on `globalThis` instead of in a module-scoped `let`. The fix is purely defensive against the dual-package case. --- packages/context/src/store.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/context/src/store.ts b/packages/context/src/store.ts index 9b472e1eb4..df3b0a9220 100644 --- a/packages/context/src/store.ts +++ b/packages/context/src/store.ts @@ -2,7 +2,27 @@ import { AsyncLocalStorage } from 'async_hooks' import type { GlobalContext } from './context.js' -let CONTEXT_STORAGE: AsyncLocalStorage> +// The singleton lives on `globalThis` (keyed by a registered Symbol) so the +// ESM and CJS variants of this package share the same `AsyncLocalStorage` +// instance. +// +// `@cedarjs/context` ships dual ESM+CJS, and Node resolves the variant per +// caller — ESM consumers (e.g. an ESM user-api package) load `dist/index.js`, +// CJS consumers (e.g. cedar's CLI loading `@cedarjs/api-server`'s CJS entry) +// load `dist/cjs/index.js`. Each variant is a separate module instance with +// its own module-scoped state. If we kept the `AsyncLocalStorage` in a +// module-scoped `let`, each variant would have its own store, and the +// `currentUser` written into one would be invisible to the other — a +// classic dual-package hazard. Stashing it on `globalThis` under a +// `Symbol.for` key bridges them: both variants resolve the same key to the +// same object, so they share the store and `context.currentUser` propagates +// between framework code and user code regardless of which variant each +// loaded. +const STORAGE_KEY = Symbol.for('__cedarjs_context_storage__') + +type ContextStorageGlobal = typeof globalThis & { + [STORAGE_KEY]?: AsyncLocalStorage> +} /** * This returns a AsyncLocalStorage instance, not the actual store. @@ -10,8 +30,11 @@ let CONTEXT_STORAGE: AsyncLocalStorage> * this. */ export const getAsyncStoreInstance = () => { - if (!CONTEXT_STORAGE) { - CONTEXT_STORAGE = new AsyncLocalStorage>() + const g = globalThis as ContextStorageGlobal + + if (!g[STORAGE_KEY]) { + g[STORAGE_KEY] = new AsyncLocalStorage>() } - return CONTEXT_STORAGE + + return g[STORAGE_KEY] }