@open-mercato/shared 0.6.8-develop.7013.1.48f7adca80 → 0.6.8-develop.7016.1.4ed7b1e49d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/events/factory.ts"],
4
- "sourcesContent": ["/**\n * Event Module Factory\n *\n * Provides factory functions for creating type-safe event configurations.\n */\n\nimport { createLogger } from '../../lib/logger'\nimport type {\n EventDefinition,\n EventModuleConfig,\n EventPayload,\n EmitOptions,\n CreateModuleEventsOptions,\n ModuleEventEmitter,\n} from './types'\n\nconst logger = createLogger('events').child({ component: 'factory' })\n\n// =============================================================================\n// Global Event Bus Reference\n// =============================================================================\n\n/**\n * Type for the global event bus interface\n */\ninterface GlobalEventBus {\n emit(event: string, payload: unknown, options?: EmitOptions): Promise<void>\n}\n\nconst GLOBAL_EVENT_BUS_KEY = '__openMercatoGlobalEventBus__'\n\n// Global event bus reference (set during bootstrap)\nlet globalEventBus: GlobalEventBus | null = null\n\n/**\n * Set the global event bus instance.\n * Called during app bootstrap to wire up event emission.\n */\nexport function setGlobalEventBus(bus: GlobalEventBus): void {\n globalEventBus = bus\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY] = bus\n } catch {\n // ignore global assignment failures\n }\n}\n\n/**\n * Get the global event bus instance.\n * Returns null if not yet bootstrapped.\n */\nexport function getGlobalEventBus(): GlobalEventBus | null {\n try {\n const sharedBus = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY]\n if (sharedBus && typeof sharedBus === 'object' && typeof (sharedBus as GlobalEventBus).emit === 'function') {\n return sharedBus as GlobalEventBus\n }\n } catch {\n // ignore global read failures\n }\n return globalEventBus\n}\n\n// =============================================================================\n// Event Registry for Validation\n// =============================================================================\n\n// Global set of all declared event IDs for runtime validation\nconst allDeclaredEventIds = new Set<string>()\n\n// Global registry of all declared events with their full definitions\nconst allDeclaredEvents: EventDefinition[] = []\n\nfunction addDeclaredEvent(event: EventDefinition): void {\n allDeclaredEventIds.add(event.id)\n // Avoid duplicates if createModuleEvents/registerEventModuleConfigs is called multiple times (e.g., HMR)\n if (!allDeclaredEvents.find(e => e.id === event.id)) {\n allDeclaredEvents.push(event)\n }\n}\n\n/**\n * Check if an event ID has been declared by any module.\n * Used for runtime validation to ensure only declared events are emitted.\n */\nexport function isEventDeclared(eventId: string): boolean {\n return allDeclaredEventIds.has(eventId)\n}\n\n/**\n * Get all declared event IDs.\n * Useful for debugging and introspection.\n */\nexport function getAllDeclaredEventIds(): string[] {\n return Array.from(allDeclaredEventIds)\n}\n\n/**\n * Get all declared events with their full definitions.\n * Used by the API to return available events for workflow triggers.\n */\nexport function getDeclaredEvents(): EventDefinition[] {\n return [...allDeclaredEvents]\n}\n\n/**\n * Check if an event has clientBroadcast enabled.\n * Used by the SSE endpoint to filter events for the DOM Event Bridge.\n */\nexport function isBroadcastEvent(eventId: string): boolean {\n const event = allDeclaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true\n}\n\n/**\n * Check if an event has portalBroadcast enabled.\n * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.\n */\nexport function isPortalBroadcastEvent(eventId: string): boolean {\n const event = allDeclaredEvents.find(e => e.id === eventId)\n return event?.portalBroadcast === true\n}\n\n// =============================================================================\n// Bootstrap Registration (similar to searchModuleConfigs pattern)\n// =============================================================================\n\nlet _registeredEventConfigs: EventModuleConfig[] | null = null\n\n/**\n * Register event module configurations globally.\n * Called during app bootstrap with configs from events.generated.ts.\n */\nexport function registerEventModuleConfigs(configs: EventModuleConfig[]): void {\n if (_registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Event module configs re-registered (this may occur during HMR)')\n }\n _registeredEventConfigs = configs\n for (const config of configs) {\n for (const event of config.events) {\n addDeclaredEvent(event)\n }\n }\n}\n\n/**\n * Get registered event module configurations.\n * Returns empty array if not registered.\n */\nexport function getEventModuleConfigs(): EventModuleConfig[] {\n return _registeredEventConfigs ?? []\n}\n\n// =============================================================================\n// Factory Function\n// =============================================================================\n\n/**\n * Creates a type-safe event configuration for a module.\n *\n * Usage in module events.ts:\n * ```typescript\n * import { createModuleEvents } from '@open-mercato/shared/modules/events'\n *\n * const events = [\n * { id: 'customers.people.created', label: 'Person Created', category: 'crud' },\n * { id: 'customers.people.updated', label: 'Person Updated', category: 'crud' },\n * ] as const\n *\n * export const eventsConfig = createModuleEvents({\n * moduleId: 'customers',\n * events,\n * })\n *\n * // Export the typed emit function for use in commands\n * export const emitCustomersEvent = eventsConfig.emit\n *\n * // Export event IDs as a type for external use\n * export type CustomersEventId = typeof events[number]['id']\n *\n * export default eventsConfig\n * ```\n *\n * TypeScript will enforce that only declared event IDs can be emitted:\n * ```typescript\n * // \u2705 This compiles - event is declared\n * emitCustomersEvent('customers.people.created', { id: '123', tenantId: 'abc' })\n *\n * // \u274C TypeScript error - event not declared\n * emitCustomersEvent('customers.people.exploded', { id: '123' })\n * ```\n */\nexport function createModuleEvents<\n const TEvents extends readonly { id: string }[],\n TEventIds extends TEvents[number]['id'] = TEvents[number]['id']\n>(options: CreateModuleEventsOptions<TEventIds>): EventModuleConfig<TEventIds> {\n const { moduleId, events, strict = false } = options\n\n // Build set of valid event IDs for runtime validation\n const validEventIds = new Set(events.map(e => e.id))\n\n // Build full event definitions with module added\n const fullEvents: EventDefinition[] = events.map(e => ({\n ...e,\n module: moduleId,\n }))\n\n // Register all event IDs and definitions in the global registry.\n for (const event of fullEvents) {\n addDeclaredEvent(event)\n }\n\n /**\n * The emit function - validates events and delegates to the global event bus\n */\n const emit = async (\n eventId: TEventIds,\n payload: EventPayload,\n emitOptions?: EmitOptions\n ): Promise<void> => {\n // Runtime validation - event must be declared\n if (!validEventIds.has(eventId)) {\n const message =\n `[events] Module \"${moduleId}\" tried to emit undeclared event \"${eventId}\". ` +\n `Add it to the module's events.ts file first.`\n\n if (strict) {\n throw new Error(message)\n } else {\n logger.error('Module tried to emit undeclared event \u2014 add it to the module events.ts first', { moduleId, eventId })\n // In non-strict mode, still emit but with warning\n }\n }\n\n // Get event bus from global reference\n const eventBus = getGlobalEventBus()\n if (!eventBus) {\n logger.warn('Event bus not available, cannot emit event', { eventId })\n return\n }\n\n await eventBus.emit(eventId, payload, emitOptions)\n }\n\n return {\n moduleId,\n events: fullEvents,\n emit: emit as unknown as ModuleEventEmitter<TEventIds>,\n }\n}\n"],
5
- "mappings": "AAMA,SAAS,oBAAoB;AAU7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,UAAU,CAAC;AAapE,MAAM,uBAAuB;AAG7B,IAAI,iBAAwC;AAMrC,SAAS,kBAAkB,KAA2B;AAC3D,mBAAiB;AACjB,MAAI;AACF;AAAC,IAAC,WAAuC,oBAAoB,IAAI;AAAA,EACnE,QAAQ;AAAA,EAER;AACF;AAMO,SAAS,oBAA2C;AACzD,MAAI;AACF,UAAM,YAAa,WAAuC,oBAAoB;AAC9E,QAAI,aAAa,OAAO,cAAc,YAAY,OAAQ,UAA6B,SAAS,YAAY;AAC1G,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAOA,MAAM,sBAAsB,oBAAI,IAAY;AAG5C,MAAM,oBAAuC,CAAC;AAE9C,SAAS,iBAAiB,OAA8B;AACtD,sBAAoB,IAAI,MAAM,EAAE;AAEhC,MAAI,CAAC,kBAAkB,KAAK,OAAK,EAAE,OAAO,MAAM,EAAE,GAAG;AACnD,sBAAkB,KAAK,KAAK;AAAA,EAC9B;AACF;AAMO,SAAS,gBAAgB,SAA0B;AACxD,SAAO,oBAAoB,IAAI,OAAO;AACxC;AAMO,SAAS,yBAAmC;AACjD,SAAO,MAAM,KAAK,mBAAmB;AACvC;AAMO,SAAS,oBAAuC;AACrD,SAAO,CAAC,GAAG,iBAAiB;AAC9B;AAMO,SAAS,iBAAiB,SAA0B;AACzD,QAAM,QAAQ,kBAAkB,KAAK,OAAK,EAAE,OAAO,OAAO;AAC1D,SAAO,OAAO,oBAAoB;AACpC;AAMO,SAAS,uBAAuB,SAA0B;AAC/D,QAAM,QAAQ,kBAAkB,KAAK,OAAK,EAAE,OAAO,OAAO;AAC1D,SAAO,OAAO,oBAAoB;AACpC;AAMA,IAAI,0BAAsD;AAMnD,SAAS,2BAA2B,SAAoC;AAC7E,MAAI,4BAA4B,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC9E,WAAO,MAAM,gEAAgE;AAAA,EAC/E;AACA,4BAA0B;AAC1B,aAAW,UAAU,SAAS;AAC5B,eAAW,SAAS,OAAO,QAAQ;AACjC,uBAAiB,KAAK;AAAA,IACxB;AAAA,EACF;AACF;AAMO,SAAS,wBAA6C;AAC3D,SAAO,2BAA2B,CAAC;AACrC;AAyCO,SAAS,mBAGd,SAA6E;AAC7E,QAAM,EAAE,UAAU,QAAQ,SAAS,MAAM,IAAI;AAG7C,QAAM,gBAAgB,IAAI,IAAI,OAAO,IAAI,OAAK,EAAE,EAAE,CAAC;AAGnD,QAAM,aAAgC,OAAO,IAAI,QAAM;AAAA,IACrD,GAAG;AAAA,IACH,QAAQ;AAAA,EACV,EAAE;AAGF,aAAW,SAAS,YAAY;AAC9B,qBAAiB,KAAK;AAAA,EACxB;AAKA,QAAM,OAAO,OACX,SACA,SACA,gBACkB;AAElB,QAAI,CAAC,cAAc,IAAI,OAAO,GAAG;AAC/B,YAAM,UACJ,oBAAoB,QAAQ,qCAAqC,OAAO;AAG1E,UAAI,QAAQ;AACV,cAAM,IAAI,MAAM,OAAO;AAAA,MACzB,OAAO;AACL,eAAO,MAAM,qFAAgF,EAAE,UAAU,QAAQ,CAAC;AAAA,MAEpH;AAAA,IACF;AAGA,UAAM,WAAW,kBAAkB;AACnC,QAAI,CAAC,UAAU;AACb,aAAO,KAAK,8CAA8C,EAAE,QAAQ,CAAC;AACrE;AAAA,IACF;AAEA,UAAM,SAAS,KAAK,SAAS,SAAS,WAAW;AAAA,EACnD;AAEA,SAAO;AAAA,IACL;AAAA,IACA,QAAQ;AAAA,IACR;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["/**\n * Event Module Factory\n *\n * Provides factory functions for creating type-safe event configurations.\n */\n\nimport { createLogger } from '../../lib/logger'\nimport type {\n EventDefinition,\n EventModuleConfig,\n EventPayload,\n EmitOptions,\n CreateModuleEventsOptions,\n ModuleEventEmitter,\n} from './types'\n\nconst logger = createLogger('events').child({ component: 'factory' })\n\n// =============================================================================\n// Global Event Bus Reference\n// =============================================================================\n\n/**\n * Type for the global event bus interface\n */\ninterface GlobalEventBus {\n emit(event: string, payload: unknown, options?: EmitOptions): Promise<void>\n}\n\nconst GLOBAL_EVENT_BUS_KEY = '__openMercatoGlobalEventBus__'\n\n// Global event bus reference (set during bootstrap)\nlet globalEventBus: GlobalEventBus | null = null\n\n/**\n * Set the global event bus instance.\n * Called during app bootstrap to wire up event emission.\n */\nexport function setGlobalEventBus(bus: GlobalEventBus): void {\n globalEventBus = bus\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY] = bus\n } catch {\n // ignore global assignment failures\n }\n}\n\n/**\n * Get the global event bus instance.\n * Returns null if not yet bootstrapped.\n */\nexport function getGlobalEventBus(): GlobalEventBus | null {\n try {\n const sharedBus = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY]\n if (sharedBus && typeof sharedBus === 'object' && typeof (sharedBus as GlobalEventBus).emit === 'function') {\n return sharedBus as GlobalEventBus\n }\n } catch {\n // ignore global read failures\n }\n return globalEventBus\n}\n\n// =============================================================================\n// Event Registry for Validation\n// =============================================================================\n\ntype EventRegistryState = {\n declaredEventIds: Set<string>\n declaredEvents: EventDefinition[]\n registeredEventConfigs: EventModuleConfig[] | null\n}\n\nconst GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'\n\nconst fallbackEventRegistryState: EventRegistryState = {\n declaredEventIds: new Set<string>(),\n declaredEvents: [],\n registeredEventConfigs: null,\n}\n\nfunction isEventRegistryState(value: unknown): value is EventRegistryState {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Partial<EventRegistryState>\n return candidate.declaredEventIds instanceof Set\n && Array.isArray(candidate.declaredEvents)\n && (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))\n}\n\nfunction getEventRegistryState(): EventRegistryState {\n try {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]\n if (isEventRegistryState(existing)) return existing\n globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState\n return fallbackEventRegistryState\n } catch {\n // Restricted runtimes may deny global access. Keep the previous\n // module-local behavior as a safe fallback.\n return fallbackEventRegistryState\n }\n}\n\nfunction addDeclaredEvent(event: EventDefinition): void {\n const state = getEventRegistryState()\n state.declaredEventIds.add(event.id)\n const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id)\n if (existingIndex < 0) {\n state.declaredEvents.push(event)\n return\n }\n // Refresh a module's own definition in place during HMR without allowing a\n // duplicate declaration from another module to take over the event id.\n if (state.declaredEvents[existingIndex]?.module === event.module) {\n state.declaredEvents[existingIndex] = event\n }\n}\n\n/**\n * Check if an event ID has been declared by any module.\n * Used for runtime validation to ensure only declared events are emitted.\n */\nexport function isEventDeclared(eventId: string): boolean {\n return getEventRegistryState().declaredEventIds.has(eventId)\n}\n\n/**\n * Get all declared event IDs.\n * Useful for debugging and introspection.\n */\nexport function getAllDeclaredEventIds(): string[] {\n return Array.from(getEventRegistryState().declaredEventIds)\n}\n\n/**\n * Get all declared events with their full definitions.\n * Used by the API to return available events for workflow triggers.\n */\nexport function getDeclaredEvents(): EventDefinition[] {\n return [...getEventRegistryState().declaredEvents]\n}\n\n/**\n * Check if an event has clientBroadcast enabled.\n * Used by the SSE endpoint to filter events for the DOM Event Bridge.\n */\nexport function isBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true\n}\n\n/**\n * Check if an event should be published over the server-to-server event bridge.\n * Browser-broadcast events remain eligible for backward compatibility, while\n * crossProcessBroadcast supports private process coordination without SSE.\n */\nexport function isCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true || event?.crossProcessBroadcast === true\n}\n\n/**\n * Check whether an event is reserved for private server-to-server\n * coordination. Workflow-authored EMIT_EVENT activities must not emit these\n * events because their payload and event id are tenant-managed input.\n */\nexport function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true\n}\n\n/**\n * Verify provenance for a private cross-process event. The module id is\n * stamped by a declared module emitter or another trusted server-side seam;\n * tenant-managed event payloads never participate in this decision.\n */\nexport function isPrivateCrossProcessEventEmitter(\n eventId: string,\n emitterModuleId: string | undefined,\n): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n if (event?.crossProcessBroadcast !== true) return true\n return typeof event.module === 'string'\n && event.module.length > 0\n && event.module === emitterModuleId\n}\n\n/**\n * Check if an event has portalBroadcast enabled.\n * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.\n */\nexport function isPortalBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.portalBroadcast === true\n}\n\n// =============================================================================\n// Bootstrap Registration (similar to searchModuleConfigs pattern)\n// =============================================================================\n\n/**\n * Register event module configurations globally.\n * Called during app bootstrap with configs from events.generated.ts.\n */\nexport function registerEventModuleConfigs(configs: EventModuleConfig[]): void {\n const state = getEventRegistryState()\n if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Event module configs re-registered (this may occur during HMR)')\n }\n state.registeredEventConfigs = configs\n for (const config of configs) {\n for (const event of config.events) {\n addDeclaredEvent(event)\n }\n }\n}\n\n/**\n * Get registered event module configurations.\n * Returns empty array if not registered.\n */\nexport function getEventModuleConfigs(): EventModuleConfig[] {\n return getEventRegistryState().registeredEventConfigs ?? []\n}\n\n// =============================================================================\n// Factory Function\n// =============================================================================\n\n/**\n * Creates a type-safe event configuration for a module.\n *\n * Usage in module events.ts:\n * ```typescript\n * import { createModuleEvents } from '@open-mercato/shared/modules/events'\n *\n * const events = [\n * { id: 'customers.people.created', label: 'Person Created', category: 'crud' },\n * { id: 'customers.people.updated', label: 'Person Updated', category: 'crud' },\n * ] as const\n *\n * export const eventsConfig = createModuleEvents({\n * moduleId: 'customers',\n * events,\n * })\n *\n * // Export the typed emit function for use in commands\n * export const emitCustomersEvent = eventsConfig.emit\n *\n * // Export event IDs as a type for external use\n * export type CustomersEventId = typeof events[number]['id']\n *\n * export default eventsConfig\n * ```\n *\n * TypeScript will enforce that only declared event IDs can be emitted:\n * ```typescript\n * // \u2705 This compiles - event is declared\n * emitCustomersEvent('customers.people.created', { id: '123', tenantId: 'abc' })\n *\n * // \u274C TypeScript error - event not declared\n * emitCustomersEvent('customers.people.exploded', { id: '123' })\n * ```\n */\nexport function createModuleEvents<\n const TEvents extends readonly { id: string }[],\n TEventIds extends TEvents[number]['id'] = TEvents[number]['id']\n>(options: CreateModuleEventsOptions<TEventIds>): EventModuleConfig<TEventIds> {\n const { moduleId, events, strict = false } = options\n\n // Build set of valid event IDs for runtime validation\n const validEventIds = new Set(events.map(e => e.id))\n\n // Build full event definitions with module added\n const fullEvents: EventDefinition[] = events.map(e => ({\n ...e,\n module: moduleId,\n }))\n\n // Register all event IDs and definitions in the global registry.\n for (const event of fullEvents) {\n addDeclaredEvent(event)\n }\n\n /**\n * The emit function - validates events and delegates to the global event bus\n */\n const emit = async (\n eventId: TEventIds,\n payload: EventPayload,\n emitOptions?: EmitOptions\n ): Promise<void> => {\n // Runtime validation - event must be declared\n if (!validEventIds.has(eventId)) {\n const message =\n `[events] Module \"${moduleId}\" tried to emit undeclared event \"${eventId}\". ` +\n `Add it to the module's events.ts file first.`\n\n if (strict) {\n throw new Error(message)\n } else {\n logger.error('Module tried to emit undeclared event \u2014 add it to the module events.ts first', { moduleId, eventId })\n // In non-strict mode, still emit but with warning\n }\n }\n\n // Get event bus from global reference\n const eventBus = getGlobalEventBus()\n if (!eventBus) {\n logger.warn('Event bus not available, cannot emit event', { eventId })\n return\n }\n\n const eventDefinition = fullEvents.find((event) => event.id === eventId)\n const isClientBroadcast = eventDefinition?.clientBroadcast === true\n const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast\n ? {\n ...emitOptions,\n // Browser-broadcast module emitters historically accepted scope in\n // their typed payload. Preserve that contract at the trusted module\n // boundary while the event bus itself relies only on options.\n ...(isClientBroadcast && emitOptions?.tenantId === undefined\n ? { tenantId: payload.tenantId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationId === undefined\n ? { organizationId: payload.organizationId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)\n ? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }\n : {}),\n emitterModuleId: moduleId,\n }\n : emitOptions\n await eventBus.emit(eventId, payload, trustedOptions)\n }\n\n return {\n moduleId,\n events: fullEvents,\n emit: emit as unknown as ModuleEventEmitter<TEventIds>,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.8-develop.7013.1.48f7adca80",
3
+ "version": "0.6.8-develop.7016.1.4ed7b1e49d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,6 +40,10 @@
40
40
  "types": "./src/lib/bootstrap/dynamicLoader.ts",
41
41
  "default": "./dist/lib/bootstrap/dynamicLoader.js"
42
42
  },
43
+ "./lib/auth/principal-service": {
44
+ "types": "./src/lib/auth/principal-service.ts",
45
+ "default": "./dist/lib/auth/principal-service.js"
46
+ },
43
47
  "./lib/webhooks": {
44
48
  "types": "./src/lib/webhooks/index.ts",
45
49
  "default": "./dist/lib/webhooks/index.js"
@@ -105,7 +109,7 @@
105
109
  "@mikro-orm/core": "^7.1.8",
106
110
  "@mikro-orm/decorators": "^7.1.8",
107
111
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.8-develop.7013.1.48f7adca80",
112
+ "@open-mercato/cache": "0.6.8-develop.7016.1.4ed7b1e49d",
109
113
  "@types/sanitize-html": "^2.16.1",
110
114
  "dotenv": "^17.4.2",
111
115
  "pino": "^10.3.1",
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
5
+ const SUBPATH = './lib/auth/principal-service'
6
+
7
+ type ExportTarget = { types?: string | string[]; default?: string }
8
+
9
+ function readExports(): Record<string, ExportTarget | string> {
10
+ const packageJson = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as {
11
+ exports?: Record<string, ExportTarget | string>
12
+ }
13
+ return packageJson.exports ?? {}
14
+ }
15
+
16
+ /**
17
+ * Node's subpath resolution: an exact key wins outright, and only when none
18
+ * matches do the `*` patterns compete on the longest static prefix. Mirroring
19
+ * it here is the point of the test — a subpath that resolves only through a
20
+ * wildcard is at the mercy of whichever resolver a consumer happens to run
21
+ * (packed tarball, bundler, Jest), and those do not agree on wildcard handling.
22
+ */
23
+ function resolveSubpath(exports: Record<string, ExportTarget | string>, subpath: string): string | null {
24
+ if (Object.prototype.hasOwnProperty.call(exports, subpath)) return subpath
25
+ let best: string | null = null
26
+ for (const key of Object.keys(exports)) {
27
+ const star = key.indexOf('*')
28
+ if (star < 0) continue
29
+ const prefix = key.slice(0, star)
30
+ const suffix = key.slice(star + 1)
31
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
32
+ if (subpath.length < prefix.length + suffix.length) continue
33
+ if (!best || prefix.length > best.slice(0, best.indexOf('*')).length) best = key
34
+ }
35
+ return best
36
+ }
37
+
38
+ describe('@open-mercato/shared principal-service export map', () => {
39
+ const exports = readExports()
40
+
41
+ it('resolves through an explicit entry rather than a wildcard pattern', () => {
42
+ expect(resolveSubpath(exports, SUBPATH)).toBe(SUBPATH)
43
+ })
44
+
45
+ it('maps types to the source module and default to the built module', () => {
46
+ const target = exports[SUBPATH]
47
+ expect(typeof target).toBe('object')
48
+ expect(target).toEqual({
49
+ types: './src/lib/auth/principal-service.ts',
50
+ default: './dist/lib/auth/principal-service.js',
51
+ })
52
+ })
53
+
54
+ // Both halves of the mapping must exist in a packed package. `default` is
55
+ // produced by `yarn build:packages`, which the validation gate runs before
56
+ // `yarn test`.
57
+ it('ships both mapped files', () => {
58
+ const target = exports[SUBPATH] as ExportTarget
59
+ expect(existsSync(join(PACKAGE_ROOT, target.types as string))).toBe(true)
60
+ expect(existsSync(join(PACKAGE_ROOT, target.default as string))).toBe(true)
61
+ })
62
+
63
+ it('keeps the module importable under the mapped default condition', async () => {
64
+ const target = exports[SUBPATH] as ExportTarget
65
+ await expect(import(join(PACKAGE_ROOT, target.default as string))).resolves.toBeDefined()
66
+ })
67
+ })
@@ -94,6 +94,244 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
94
94
  expect(emFlush).toHaveBeenCalledTimes(1)
95
95
  })
96
96
 
97
+ it('retains the creator identity for a regular tenant-scoped key', async () => {
98
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
99
+ findApiKeyBySecret.mockResolvedValue({
100
+ id: 'key-tenant-scoped',
101
+ name: 'tenant scoped',
102
+ tenantId: 'tenant-1',
103
+ organizationId: null,
104
+ rolesJson: [],
105
+ sessionToken: null,
106
+ sessionUserId: null,
107
+ sessionSecretEncrypted: null,
108
+ opencodeSessionId: null,
109
+ createdBy: 'creator-1',
110
+ expiresAt: null,
111
+ lastUsedAt: null,
112
+ })
113
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
114
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
115
+ if (where.id === 'creator-1') {
116
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'creator-org' }
117
+ }
118
+ return null
119
+ })
120
+
121
+ const auth = await getAuthFromRequest(buildRequest('tenant-scoped-secret'))
122
+
123
+ expect(auth).toMatchObject({
124
+ sub: 'api_key:key-tenant-scoped',
125
+ tenantId: 'tenant-1',
126
+ orgId: null,
127
+ isApiKey: true,
128
+ keyId: 'key-tenant-scoped',
129
+ userId: 'creator-1',
130
+ })
131
+ // The creator remains the key's legacy identity, but its concrete
132
+ // organization does not narrow a tenant-scoped key.
133
+ expect(emFindOne).toHaveBeenCalledWith(
134
+ expect.anything(),
135
+ { id: 'creator-1', deletedAt: null },
136
+ )
137
+ })
138
+
139
+ it('rejects a tenant-scoped regular key once its creator is soft-deleted', async () => {
140
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
141
+ findApiKeyBySecret.mockResolvedValue({
142
+ id: 'key-deleted-creator',
143
+ name: 'deleted creator',
144
+ tenantId: 'tenant-1',
145
+ organizationId: null,
146
+ rolesJson: [],
147
+ sessionToken: null,
148
+ sessionUserId: null,
149
+ sessionSecretEncrypted: null,
150
+ opencodeSessionId: null,
151
+ createdBy: 'creator-1',
152
+ expiresAt: null,
153
+ lastUsedAt: null,
154
+ })
155
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
156
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
157
+ return null
158
+ })
159
+
160
+ await expect(
161
+ getAuthFromRequest(buildRequest('deleted-creator-secret')),
162
+ ).resolves.toBeNull()
163
+ })
164
+
165
+ it('rejects a tenant-scoped regular key whose creator moved to another tenant', async () => {
166
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
167
+ findApiKeyBySecret.mockResolvedValue({
168
+ id: 'key-foreign-creator',
169
+ name: 'foreign creator',
170
+ tenantId: 'tenant-1',
171
+ organizationId: null,
172
+ rolesJson: [],
173
+ sessionToken: null,
174
+ sessionUserId: null,
175
+ sessionSecretEncrypted: null,
176
+ opencodeSessionId: null,
177
+ createdBy: 'creator-1',
178
+ expiresAt: null,
179
+ lastUsedAt: null,
180
+ })
181
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
182
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
183
+ if (where.id === 'creator-1') {
184
+ return { id: 'creator-1', tenantId: 'tenant-2', organizationId: null }
185
+ }
186
+ return null
187
+ })
188
+
189
+ await expect(
190
+ getAuthFromRequest(buildRequest('foreign-creator-secret')),
191
+ ).resolves.toBeNull()
192
+ })
193
+
194
+ it('accepts a tenant-scoped regular key that records no creator', async () => {
195
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
196
+ findApiKeyBySecret.mockResolvedValue({
197
+ id: 'key-no-creator',
198
+ name: 'no creator',
199
+ tenantId: 'tenant-1',
200
+ organizationId: null,
201
+ rolesJson: [],
202
+ sessionToken: null,
203
+ sessionUserId: null,
204
+ sessionSecretEncrypted: null,
205
+ opencodeSessionId: null,
206
+ createdBy: null,
207
+ expiresAt: null,
208
+ lastUsedAt: null,
209
+ })
210
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
211
+ if (where.id === 'tenant-1' && where.isActive === true) return { id: 'tenant-1' }
212
+ return null
213
+ })
214
+
215
+ await expect(
216
+ getAuthFromRequest(buildRequest('no-creator-secret')),
217
+ ).resolves.toMatchObject({ keyId: 'key-no-creator', tenantId: 'tenant-1' })
218
+ })
219
+
220
+ it('retains the creator identity for an organization-scoped regular key', async () => {
221
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
222
+ findApiKeyBySecret.mockResolvedValue({
223
+ id: 'key-organization-scoped',
224
+ name: 'organization scoped',
225
+ tenantId: 'tenant-1',
226
+ organizationId: 'org-1',
227
+ rolesJson: [],
228
+ sessionToken: null,
229
+ sessionUserId: null,
230
+ sessionSecretEncrypted: null,
231
+ opencodeSessionId: null,
232
+ createdBy: 'creator-1',
233
+ expiresAt: null,
234
+ lastUsedAt: null,
235
+ })
236
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
237
+ if (where.id === 'creator-1') {
238
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-1' }
239
+ }
240
+ return null
241
+ })
242
+
243
+ await expect(
244
+ getAuthFromRequest(buildRequest('organization-scoped-secret')),
245
+ ).resolves.toMatchObject({
246
+ sub: 'api_key:key-organization-scoped',
247
+ tenantId: 'tenant-1',
248
+ orgId: 'org-1',
249
+ userId: 'creator-1',
250
+ })
251
+ })
252
+
253
+ it('rejects an organization-scoped regular key when its creator scope no longer matches', async () => {
254
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
255
+ findApiKeyBySecret.mockResolvedValue({
256
+ id: 'key-organization-mismatch',
257
+ name: 'organization mismatch',
258
+ tenantId: 'tenant-1',
259
+ organizationId: 'org-1',
260
+ rolesJson: [],
261
+ sessionToken: null,
262
+ sessionUserId: null,
263
+ sessionSecretEncrypted: null,
264
+ opencodeSessionId: null,
265
+ createdBy: 'creator-1',
266
+ expiresAt: null,
267
+ lastUsedAt: null,
268
+ })
269
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
270
+ if (where.id === 'creator-1') {
271
+ return { id: 'creator-1', tenantId: 'tenant-1', organizationId: 'org-2' }
272
+ }
273
+ return null
274
+ })
275
+
276
+ await expect(
277
+ getAuthFromRequest(buildRequest('organization-scope-mismatch-secret')),
278
+ ).resolves.toBeNull()
279
+ })
280
+
281
+ it('keeps session keys strictly bound to their persisted user and scope', async () => {
282
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
283
+ findApiKeyBySecret.mockResolvedValue({
284
+ id: 'key-session-scoped',
285
+ name: 'session scoped',
286
+ tenantId: 'tenant-1',
287
+ organizationId: null,
288
+ rolesJson: [],
289
+ sessionToken: 'sess_123',
290
+ sessionUserId: 'session-user-1',
291
+ sessionSecretEncrypted: null,
292
+ opencodeSessionId: null,
293
+ createdBy: 'session-user-1',
294
+ expiresAt: null,
295
+ lastUsedAt: null,
296
+ })
297
+ emFindOne.mockImplementation(async (_entity: unknown, where: Record<string, unknown>) => {
298
+ if (where.id === 'session-user-1') {
299
+ return { id: 'session-user-1', tenantId: 'tenant-1', organizationId: 'user-org' }
300
+ }
301
+ return null
302
+ })
303
+
304
+ await expect(
305
+ getAuthFromRequest(buildRequest('session-scope-mismatch-secret')),
306
+ ).resolves.toBeNull()
307
+ })
308
+
309
+ it('fails closed when a row has session markers but no bound session user', async () => {
310
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
311
+ findApiKeyBySecret.mockResolvedValue({
312
+ id: 'key-malformed-session',
313
+ name: 'malformed session',
314
+ tenantId: 'tenant-1',
315
+ organizationId: null,
316
+ rolesJson: [],
317
+ sessionToken: 'sess_missing_user',
318
+ sessionUserId: null,
319
+ sessionSecretEncrypted: null,
320
+ opencodeSessionId: null,
321
+ createdBy: 'creator-1',
322
+ expiresAt: null,
323
+ lastUsedAt: null,
324
+ })
325
+
326
+ await expect(
327
+ getAuthFromRequest(buildRequest('malformed-session-secret')),
328
+ ).resolves.toBeNull()
329
+ expect(emFindOne).not.toHaveBeenCalledWith(
330
+ expect.anything(),
331
+ expect.objectContaining({ id: 'creator-1' }),
332
+ )
333
+ })
334
+
97
335
  it('caches negative lookups so invalid keys skip the bcrypt+DB path', async () => {
98
336
  const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
99
337
  findApiKeyBySecret.mockResolvedValue(null)
@@ -128,4 +366,90 @@ describe('resolveApiKeyAuth caching + lastUsedAt debounce', () => {
128
366
  await getAuthFromRequest(buildRequest('secret-invalidate'))
129
367
  expect(findApiKeyBySecret).toHaveBeenCalledTimes(2)
130
368
  })
369
+
370
+ describe('super-admin bit stays bounded by the effective key scope', () => {
371
+ async function resolveWithSuperAdminRole(input: {
372
+ secret: string
373
+ keyId: string
374
+ keyOrganizationId: string | null
375
+ aclOrganizations: string[] | null
376
+ }) {
377
+ const { RoleAcl } = await import('@open-mercato/core/modules/auth/data/entities')
378
+ const { Organization, Tenant } = await import('@open-mercato/core/modules/directory/data/entities')
379
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
380
+ // A creator-less key is validated against a live tenant (and organization when bound).
381
+ emFindOne.mockImplementation(async (entity: unknown) => {
382
+ if (entity === Tenant) return { id: 'tenant-1' }
383
+ if (entity === Organization) return { id: input.keyOrganizationId, tenant: { id: 'tenant-1' } }
384
+ return null
385
+ })
386
+ findApiKeyBySecret.mockResolvedValue({
387
+ id: input.keyId,
388
+ name: 'super admin key',
389
+ tenantId: 'tenant-1',
390
+ organizationId: input.keyOrganizationId,
391
+ rolesJson: ['role-super'],
392
+ sessionToken: null,
393
+ sessionUserId: null,
394
+ sessionSecretEncrypted: null,
395
+ opencodeSessionId: null,
396
+ createdBy: null,
397
+ expiresAt: null,
398
+ lastUsedAt: null,
399
+ })
400
+ emFind.mockImplementation(async (entity: unknown) => {
401
+ if (entity === RoleAcl) {
402
+ return [{ isSuperAdmin: true, organizationsJson: input.aclOrganizations }]
403
+ }
404
+ return []
405
+ })
406
+ return getAuthFromRequest(buildRequest(input.secret))
407
+ }
408
+
409
+ it('withholds it from an organization-bound key even when a role grants it', async () => {
410
+ // Generic guards trust this bit before live RBAC, so an organization-restricted key must
411
+ // never take super-admin shortcuts outside its own scope.
412
+ const auth = await resolveWithSuperAdminRole({
413
+ secret: 'super-bound',
414
+ keyId: 'key-super-bound',
415
+ keyOrganizationId: 'org-1',
416
+ aclOrganizations: null,
417
+ })
418
+
419
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
420
+ })
421
+
422
+ it('withholds it when the granting role ACL is itself organization-restricted', async () => {
423
+ const auth = await resolveWithSuperAdminRole({
424
+ secret: 'super-restricted',
425
+ keyId: 'key-super-restricted',
426
+ keyOrganizationId: null,
427
+ aclOrganizations: ['org-1'],
428
+ })
429
+
430
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: false })
431
+ })
432
+
433
+ it('grants it for an unbound key with an unrestricted role grant', async () => {
434
+ const auth = await resolveWithSuperAdminRole({
435
+ secret: 'super-global',
436
+ keyId: 'key-super-global',
437
+ keyOrganizationId: null,
438
+ aclOrganizations: null,
439
+ })
440
+
441
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
442
+ })
443
+
444
+ it('grants it for an unbound key whose role ACL uses the __all__ sentinel', async () => {
445
+ const auth = await resolveWithSuperAdminRole({
446
+ secret: 'super-all-sentinel',
447
+ keyId: 'key-super-all-sentinel',
448
+ keyOrganizationId: null,
449
+ aclOrganizations: ['__all__'],
450
+ })
451
+
452
+ expect(auth).toMatchObject({ isApiKey: true, isSuperAdmin: true })
453
+ })
454
+ })
131
455
  })
@@ -0,0 +1,110 @@
1
+ import type { AuthContext } from './server'
2
+
3
+ export type PrincipalScope = {
4
+ tenantId: string
5
+ organizationId: string
6
+ }
7
+
8
+ export type AuthPrincipalType = 'user' | 'role'
9
+
10
+ export type AuthPrincipalLabel = {
11
+ id: string
12
+ label: string
13
+ secondary: string | null
14
+ }
15
+
16
+ export type AuthPrincipalRolePage = {
17
+ items: AuthPrincipalLabel[]
18
+ page: number
19
+ pageSize: number
20
+ total: number
21
+ }
22
+
23
+ /** Public, request-scoped read boundary owned by the Auth module. */
24
+ export interface AuthPrincipalService {
25
+ principalExists(input: {
26
+ type: AuthPrincipalType
27
+ id: string
28
+ scope: PrincipalScope
29
+ }): Promise<boolean>
30
+ resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
31
+ filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
32
+ resolveLabels(input: {
33
+ type: AuthPrincipalType
34
+ ids: string[]
35
+ scope: PrincipalScope
36
+ }): Promise<AuthPrincipalLabel[]>
37
+ /**
38
+ * Optional additive capability for organization-eligible role pickers.
39
+ * Implementations must apply eligibility before pagination and bound the
40
+ * advertised result window so sparse ACL matches cannot amplify requests.
41
+ */
42
+ queryActiveRolePage?(input: {
43
+ scope: PrincipalScope
44
+ search?: string
45
+ excludedIds?: string[]
46
+ page: number
47
+ pageSize: number
48
+ }): Promise<AuthPrincipalRolePage>
49
+ listSuperAdminUserIds(tenantId: string): Promise<string[]>
50
+ }
51
+
52
+ /** Public, request-scoped read boundary owned by the API Keys module. */
53
+ export interface ApiKeyPrincipalService {
54
+ resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
55
+ }
56
+
57
+ export type OrganizationScope = {
58
+ selectedId: string | null
59
+ filterIds: string[] | null
60
+ allowedIds: string[] | null
61
+ tenantId: string | null
62
+ // True when an explicit organization selection could not be honored. Reads
63
+ // fall back to the caller's accessible organizations; writes must fail.
64
+ selectionRejected?: boolean
65
+ }
66
+
67
+ export type OrganizationScopeAcl = {
68
+ isSuperAdmin: boolean
69
+ features: string[]
70
+ organizations: string[] | null
71
+ }
72
+
73
+ export type OrganizationScopeRequest = Request | {
74
+ cookies?: { get: (name: string) => { value: string } | undefined }
75
+ headers?: { get(name: string): string | null }
76
+ }
77
+
78
+ /**
79
+ * Narrow, request-scoped hierarchy boundary owned by Directory.
80
+ *
81
+ * `null` means the selected organization does not exist in the requested
82
+ * tenant. An empty array means it exists but has no ancestors.
83
+ */
84
+ export interface OrganizationHierarchyService {
85
+ resolveAncestorIds(input: {
86
+ tenantId: string
87
+ organizationId: string
88
+ }): Promise<string[] | null>
89
+ }
90
+
91
+ /** Public, request-scoped organization expansion boundary owned by Directory. */
92
+ export interface OrganizationScopeService {
93
+ resolve(input: {
94
+ auth: AuthContext | null | undefined
95
+ selectedId?: string | null
96
+ tenantId?: string | null
97
+ freshAcl?: boolean
98
+ }): Promise<OrganizationScope>
99
+ resolveFresh(input: {
100
+ auth: NonNullable<AuthContext>
101
+ selectedId?: string | null
102
+ tenantId?: string | null
103
+ }): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
104
+ resolveForRequest(input: {
105
+ auth: AuthContext | null | undefined
106
+ request?: OrganizationScopeRequest
107
+ selectedId?: string | null
108
+ tenantId?: string | null
109
+ }): Promise<OrganizationScope>
110
+ }