@open-mercato/shared 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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,3 +1,48 @@
1
+ const EXTENSION_HOST_FAMILIES = [
2
+ "generic",
3
+ "menu",
4
+ "data-table",
5
+ "crud-form",
6
+ "detail",
7
+ "portal-page",
8
+ "component-handle",
9
+ "entity",
10
+ "api-route",
11
+ "command",
12
+ "event",
13
+ "query-lifecycle",
14
+ "dashboard",
15
+ "notification",
16
+ "integration",
17
+ "specialized-registry",
18
+ "module-override"
19
+ ];
20
+ const EXTENSION_HOST_CAPABILITIES = [
21
+ "render-widget",
22
+ "headless-widget",
23
+ "menu-item",
24
+ "column-widget",
25
+ "row-action",
26
+ "bulk-action",
27
+ "filter-widget",
28
+ "toolbar-widget",
29
+ "field-widget",
30
+ "lifecycle-handler",
31
+ "component-replacement",
32
+ "response-enricher",
33
+ "query-enricher",
34
+ "api-interceptor",
35
+ "command-interceptor",
36
+ "mutation-guard",
37
+ "entity-extension",
38
+ "async-subscriber",
39
+ "sync-subscriber",
40
+ "browser-client",
41
+ "browser-portal",
42
+ "registry-contribution",
43
+ "module-override"
44
+ ];
45
+ const EXTENSION_HOST_ACTIVATIONS = ["always", "host-opt-in", "caller-opt-in", "feature-gated"];
1
46
  function defineModuleExtensionPoints(declaration) {
2
47
  return Object.freeze({
3
48
  ...declaration,
@@ -36,6 +81,7 @@ const DATA_TABLE_EXTENSION_SURFACES = [
36
81
  { key: "replacement", suffix: null, capabilities: ["component-replacement"], bound: true },
37
82
  { key: "emptyState", suffix: "empty-state", capabilities: ["render-widget"], bound: false }
38
83
  ];
84
+ const DATA_TABLE_EXTENSION_SURFACE_KEYS = DATA_TABLE_EXTENSION_SURFACES.map((surface) => surface.key);
39
85
  const CRUD_FORM_EXTENSION_SURFACES = [
40
86
  { key: "base", suffix: null, capabilities: ["render-widget", "lifecycle-handler"], bound: true },
41
87
  { key: "header", suffix: "header", capabilities: ["render-widget"], bound: true },
@@ -49,6 +95,7 @@ const CRUD_FORM_EXTENSION_SURFACES = [
49
95
  { key: "fieldBefore", suffix: "field:{fieldId}:before", capabilities: ["render-widget"], bound: false },
50
96
  { key: "fieldAfter", suffix: "field:{fieldId}:after", capabilities: ["render-widget"], bound: false }
51
97
  ];
98
+ const CRUD_FORM_EXTENSION_SURFACE_KEYS = CRUD_FORM_EXTENSION_SURFACES.map((surface) => surface.key);
52
99
  const CRUD_FORM_LIFECYCLE_PHASES = [
53
100
  "transformValidation",
54
101
  "transformDisplayData",
@@ -78,11 +125,95 @@ function extensionSpotChildId(spotId, suffix) {
78
125
  function resolveExtensionPointPattern(pattern, parameters) {
79
126
  return pattern.replace(/\{([^}]+)\}/g, (token, parameterName) => parameters[parameterName] ?? token);
80
127
  }
128
+ const MODULE_EXTENSION_CONTRIBUTION_KINDS = [
129
+ "widget",
130
+ "data-table",
131
+ "crud-form",
132
+ "component-override",
133
+ "response-enricher",
134
+ "api-interceptor",
135
+ "command-interceptor",
136
+ "mutation-guard",
137
+ "entity-extension",
138
+ "subscriber",
139
+ "browser-reaction",
140
+ "specialized-registry",
141
+ "module-override"
142
+ ];
143
+ const MODULE_EXTENSION_ACTIVATION_KINDS = [
144
+ "crud-response-enricher",
145
+ "query-enricher",
146
+ "mutation-guard",
147
+ "api-interceptor-bridge",
148
+ "command-interceptor-bridge",
149
+ "widget-injection-consumer",
150
+ "component-extension-consumer",
151
+ "dashboard-host-consumer"
152
+ ];
153
+ const MODULE_EXTENSION_TARGET_KINDS = [
154
+ "module",
155
+ "entity",
156
+ "api-route",
157
+ "command",
158
+ "widget-spot",
159
+ "component",
160
+ "event",
161
+ "notification",
162
+ "wildcard"
163
+ ];
164
+ const MODULE_EXTENSION_RESOLUTIONS = [
165
+ "bound",
166
+ "capability-only",
167
+ "optional-target-missing",
168
+ "wildcard",
169
+ "unresolved"
170
+ ];
171
+ const MODULE_EXTENSION_HOST_RESOLUTIONS = ["exact", "pattern", "framework", "fact-ref"];
172
+ const MODULE_EXTENSION_TARGET_RESOLUTIONS = [
173
+ "exact",
174
+ "pattern",
175
+ "framework",
176
+ "fact-ref",
177
+ "optional-external",
178
+ "unresolved"
179
+ ];
180
+ const MODULE_SPECIALIZED_REGISTRIES = [
181
+ "notification",
182
+ "integration",
183
+ "search",
184
+ "vector",
185
+ "ai",
186
+ "payment",
187
+ "shipping",
188
+ "currency",
189
+ "workflow"
190
+ ];
191
+ const COMPONENT_OVERRIDE_MODES = ["replace", "wrapper", "props"];
192
+ const MODULE_EXTENSION_UNRESOLVED_REASONS = [
193
+ "unclassified-binding",
194
+ "unbound-declaration",
195
+ "dynamic-without-pattern",
196
+ "unresolved-first-party-target"
197
+ ];
81
198
  export {
199
+ COMPONENT_OVERRIDE_MODES,
82
200
  CRUD_FORM_EXTENSION_SURFACES,
201
+ CRUD_FORM_EXTENSION_SURFACE_KEYS,
83
202
  CRUD_FORM_LIFECYCLE_PHASES,
84
203
  CRUD_FORM_OPERATIONS,
85
204
  DATA_TABLE_EXTENSION_SURFACES,
205
+ DATA_TABLE_EXTENSION_SURFACE_KEYS,
206
+ EXTENSION_HOST_ACTIVATIONS,
207
+ EXTENSION_HOST_CAPABILITIES,
208
+ EXTENSION_HOST_FAMILIES,
209
+ MODULE_EXTENSION_ACTIVATION_KINDS,
210
+ MODULE_EXTENSION_CONTRIBUTION_KINDS,
211
+ MODULE_EXTENSION_HOST_RESOLUTIONS,
212
+ MODULE_EXTENSION_RESOLUTIONS,
213
+ MODULE_EXTENSION_TARGET_KINDS,
214
+ MODULE_EXTENSION_TARGET_RESOLUTIONS,
215
+ MODULE_EXTENSION_UNRESOLVED_REASONS,
216
+ MODULE_SPECIALIZED_REGISTRIES,
86
217
  componentExtensionHost,
87
218
  crudFormExtensionHost,
88
219
  crudFormExtensionSpotId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/widgets/extension-points.ts"],
4
- "sourcesContent": ["export type ExtensionHostFamily =\n | 'generic'\n | 'menu'\n | 'data-table'\n | 'crud-form'\n | 'detail'\n | 'portal-page'\n | 'component-handle'\n | 'entity'\n | 'api-route'\n | 'command'\n | 'event'\n | 'query-lifecycle'\n | 'dashboard'\n | 'notification'\n | 'integration'\n | 'specialized-registry'\n | 'module-override'\n\nexport type ExtensionHostCapability =\n | 'render-widget'\n | 'headless-widget'\n | 'menu-item'\n | 'column-widget'\n | 'row-action'\n | 'bulk-action'\n | 'filter-widget'\n | 'toolbar-widget'\n | 'field-widget'\n | 'lifecycle-handler'\n | 'component-replacement'\n | 'response-enricher'\n | 'query-enricher'\n | 'api-interceptor'\n | 'command-interceptor'\n | 'mutation-guard'\n | 'entity-extension'\n | 'async-subscriber'\n | 'sync-subscriber'\n | 'browser-client'\n | 'browser-portal'\n | 'registry-contribution'\n | 'module-override'\n\nexport type ExtensionHostActivation = 'always' | 'host-opt-in' | 'caller-opt-in' | 'feature-gated'\n\nexport type ExtensionPointPatternParameter = {\n source: string\n pattern?: string\n}\n\ntype ExtensionHostDeclarationBase = {\n source: string\n contextContract?: string\n dataContract?: string\n scopeContract?: string\n runtimeContract?: string\n activation?: ExtensionHostActivation\n aliases?: readonly string[]\n fallbacks?: readonly string[]\n}\n\ntype InjectionExtensionHostDeclarationBase = ExtensionHostDeclarationBase & {\n family: Exclude<ExtensionHostFamily, 'data-table' | 'crud-form' | 'component-handle'>\n supported: readonly ExtensionHostCapability[]\n}\n\nexport type InjectionExtensionHostDeclaration = InjectionExtensionHostDeclarationBase & (\n | {\n spotId: string\n pattern?: never\n parameters?: never\n }\n | {\n spotId?: never\n pattern: string\n parameters: Readonly<Record<string, ExtensionPointPatternParameter>>\n }\n)\n\nexport type DataTableExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'data-table'\n tableId: string\n baseSpotId?: string\n}\n\nexport type CrudFormExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'crud-form'\n entityId: string\n spotId?: string\n}\n\nexport type ComponentExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'component-handle'\n componentId: string\n propsContract?: string\n}\n\nexport type ModuleExtensionHostDeclaration =\n | InjectionExtensionHostDeclaration\n | DataTableExtensionHostDeclaration\n | CrudFormExtensionHostDeclaration\n | ComponentExtensionHostDeclaration\n\nexport type ModuleExtensionPoints<\n TModuleId extends string = string,\n THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>> = Readonly<Record<string, ModuleExtensionHostDeclaration>>,\n> = {\n moduleId: TModuleId\n hosts: THosts\n}\n\nexport function defineModuleExtensionPoints<\n const TModuleId extends string,\n const THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>>,\n>(declaration: ModuleExtensionPoints<TModuleId, THosts>): ModuleExtensionPoints<TModuleId, THosts> {\n return Object.freeze({\n ...declaration,\n hosts: Object.freeze({ ...declaration.hosts }),\n })\n}\n\nexport function injectionExtensionHost<const TDeclaration extends InjectionExtensionHostDeclaration>(\n declaration: TDeclaration,\n): Readonly<TDeclaration> {\n const hasExactId = typeof declaration.spotId === 'string' && declaration.spotId.length > 0\n const hasPattern = typeof declaration.pattern === 'string' && declaration.pattern.length > 0\n if (hasExactId === hasPattern) {\n throw new Error('[internal] injection extension hosts require exactly one of spotId or pattern')\n }\n if (hasPattern && (!declaration.parameters || Object.keys(declaration.parameters).length === 0)) {\n throw new Error('[internal] patterned injection extension hosts require named parameters')\n }\n return Object.freeze({ ...declaration })\n}\n\nexport function dataTableExtensionHost<\n const TDeclaration extends Omit<DataTableExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'data-table' }> {\n return Object.freeze({ family: 'data-table', ...declaration })\n}\n\nexport function crudFormExtensionHost<\n const TDeclaration extends Omit<CrudFormExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'crud-form' }> {\n return Object.freeze({ family: 'crud-form', ...declaration })\n}\n\nexport function componentExtensionHost<\n const TDeclaration extends Omit<ComponentExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'component-handle' }> {\n return Object.freeze({ family: 'component-handle', ...declaration })\n}\n\nexport type BoundExtensionSurface = {\n key: string\n suffix: string | null\n capabilities: readonly ExtensionHostCapability[]\n bound: boolean\n phases?: readonly string[]\n operations?: readonly string[]\n}\n\nexport const DATA_TABLE_EXTENSION_SURFACES = [\n { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },\n { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: true },\n { key: 'toolbar', suffix: 'toolbar', capabilities: ['toolbar-widget'], bound: true },\n { key: 'searchTrailing', suffix: 'search-trailing', capabilities: ['render-widget'], bound: true },\n { key: 'columns', suffix: 'columns', capabilities: ['column-widget'], bound: true },\n { key: 'rowActions', suffix: 'row-actions', capabilities: ['row-action'], bound: true },\n { key: 'bulkActions', suffix: 'bulk-actions', capabilities: ['bulk-action'], bound: true },\n { key: 'filters', suffix: 'filters', capabilities: ['filter-widget'], bound: true },\n { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },\n { key: 'emptyState', suffix: 'empty-state', capabilities: ['render-widget'], bound: false },\n] as const satisfies readonly BoundExtensionSurface[]\n\nexport const CRUD_FORM_EXTENSION_SURFACES = [\n { key: 'base', suffix: null, capabilities: ['render-widget', 'lifecycle-handler'], bound: true },\n { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },\n { key: 'fields', suffix: 'fields', capabilities: ['field-widget'], bound: true },\n { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },\n { key: 'beforeFields', suffix: 'before-fields', capabilities: ['render-widget'], bound: false },\n { key: 'afterFields', suffix: 'after-fields', capabilities: ['render-widget'], bound: false },\n { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: false },\n { key: 'sidebar', suffix: 'sidebar', capabilities: ['render-widget'], bound: false },\n { key: 'group', suffix: 'group:{groupId}', capabilities: ['render-widget'], bound: false },\n { key: 'fieldBefore', suffix: 'field:{fieldId}:before', capabilities: ['render-widget'], bound: false },\n { key: 'fieldAfter', suffix: 'field:{fieldId}:after', capabilities: ['render-widget'], bound: false },\n] as const satisfies readonly BoundExtensionSurface[]\n\nexport const CRUD_FORM_LIFECYCLE_PHASES = [\n 'transformValidation',\n 'transformDisplayData',\n 'onBeforeNavigate',\n 'onAppEvent',\n 'onVisibilityChange',\n 'onBeforeDelete',\n 'onDelete',\n 'onAfterDelete',\n 'onDeleteError',\n 'onFieldChange',\n 'transformFormData',\n 'onBeforeSave',\n 'onSave',\n 'onAfterSave',\n] as const\n\nexport const CRUD_FORM_OPERATIONS = ['create', 'update', 'delete'] as const\n\nexport function dataTableExtensionSpotId(tableId: string, suffix?: string): string {\n return suffix ? `data-table:${tableId}:${suffix}` : `data-table:${tableId}`\n}\n\nexport function crudFormExtensionSpotId(entityId: string, suffix?: string): string {\n return suffix ? `crud-form:${entityId}:${suffix}` : `crud-form:${entityId}`\n}\n\nexport function extensionSpotChildId(spotId: string, suffix: string): string {\n return `${spotId}:${suffix}`\n}\n\nexport function resolveExtensionPointPattern(\n pattern: string,\n parameters: Readonly<Record<string, string>>,\n): string {\n return pattern.replace(/\\{([^}]+)\\}/g, (token, parameterName: string) => parameters[parameterName] ?? token)\n}\n\n/**\n * Portable, structural copy of the CLI provenance source reference\n * (`ModuleFactSourceRef` in `packages/cli/.../module-facts.ts`). Redefined here\n * \u2014 rather than imported \u2014 so `@open-mercato/shared` keeps zero dependency on\n * `@open-mercato/cli`. Field names are kept byte-identical so the two are\n * assignable across the package boundary.\n */\nexport type ModuleFactSourceRef = {\n sourcePath: string\n exportName?: string\n line?: number\n}\n\n/** Portable, structural copy of the CLI provenance fact reference. */\nexport type ModuleFactRef = {\n factSection: string\n factKey: string\n}\n\nexport type ModuleExtensionContributionKind = ModuleExtensionContributionFact['kind']\n\nexport type ModuleExtensionActivationKind =\n | 'crud-response-enricher'\n | 'query-enricher'\n | 'mutation-guard'\n | 'api-interceptor-bridge'\n | 'command-interceptor-bridge'\n | 'widget-injection-consumer'\n | 'component-extension-consumer'\n | 'dashboard-host-consumer'\n\nexport type ModuleExtensionTargetRef = {\n kind:\n | 'module'\n | 'entity'\n | 'api-route'\n | 'command'\n | 'widget-spot'\n | 'component'\n | 'event'\n | 'notification'\n | 'wildcard'\n id: string\n moduleId?: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n}\n\nexport type ModuleExtensionActivation = {\n id: string\n kind: ModuleExtensionActivationKind\n host: ModuleExtensionTargetRef\n contributionKinds: ModuleExtensionContributionKind[]\n phases?: string[]\n /**\n * Mutation operations the call site guards, when statically declared. A\n * contribution declaring only other operations is not bound by this activation.\n */\n operations?: string[]\n source: ModuleFactSourceRef\n bridge?: ModuleFactRef\n}\n\nexport type ModuleExtensionResolution =\n | 'bound'\n | 'capability-only'\n | 'optional-target-missing'\n | 'wildcard'\n | 'unresolved'\n\nexport type ModuleIncomingExtensionRef = {\n contributionId: string\n contributionKind: ModuleExtensionContributionKind\n contributorModuleId: string\n target: ModuleExtensionTargetRef\n activationId?: string\n resolution: ModuleExtensionResolution\n source: ModuleFactSourceRef\n}\n\nexport type ModuleContributionResolution = {\n contributionId: string\n target: ModuleExtensionTargetRef\n resolution: ModuleExtensionResolution\n activationIds: string[]\n}\n\n/**\n * Additive, optional bidirectional-topology fields layered on top of the\n * existing extension-surface facts. Every field is optional so legacy consumers\n * and pre-existing generated output remain byte-compatible.\n */\nexport type ModuleExtensionSurfaceFactsAdditions = {\n activations?: ModuleExtensionActivation[]\n incoming?: ModuleIncomingExtensionRef[]\n contributionResolutions?: ModuleContributionResolution[]\n}\n\nexport type ModuleExtensionSurfaceFacts = {\n hosts: ModuleExtensionHostFact[]\n contributions: ModuleExtensionContributionFact[]\n unresolved: ModuleExtensionUnresolvedFact[]\n} & ModuleExtensionSurfaceFactsAdditions\n\nexport type ModuleExtensionHostFact = {\n key: string\n id: string\n resolution: 'exact' | 'pattern' | 'framework' | 'fact-ref'\n family: ExtensionHostFamily\n ownerModule: string\n capabilities: ExtensionHostCapability[]\n phases?: string[]\n operations?: string[]\n contextContract?: string\n dataContract?: string\n scopeContract?: string\n runtimeContract?: string\n activation?: ExtensionHostActivation\n bound: boolean\n stability: 'frozen' | 'stable'\n source:\n | { kind: 'declaration'; path: string; symbol: string }\n | { kind: 'fact-ref'; factSection: string; factKey: string }\n | { kind: 'framework'; path: string; symbol: string }\n aliases?: string[]\n patternParameters?: Record<string, ExtensionPointPatternParameter>\n fallbacks?: string[]\n}\n\nexport type ModuleExtensionTargetFact = {\n id: string\n resolution: 'exact' | 'pattern' | 'framework' | 'fact-ref' | 'optional-external' | 'unresolved'\n factRef?: { factSection: string; factKey: string }\n optionalOwnerPackage?: string\n}\n\nexport type ModuleExtensionContributionBase = {\n id: string\n targets: ModuleExtensionTargetFact[]\n phases?: string[]\n operations?: string[]\n features?: string[]\n scopeContract: string\n activation?: ExtensionHostActivation\n placement?: { relativeTo?: string; position?: 'first' | 'last' | 'before' | 'after'; priority?: number }\n roundTripId?: string\n override?: { domain: string; key: string; mode: 'disable-replace' | 'replace' | 'additive' }\n source: { path: string; symbol?: string }\n}\n\nexport type ModuleExtensionContributionFact = ModuleExtensionContributionBase & (\n | {\n kind: 'widget'\n details: {\n payload: 'render' | 'headless' | 'menu' | 'dashboard' | 'notification' | 'integration'\n registryKey: string\n itemIds?: string[]\n labelKeys?: string[]\n contextContract?: string\n dataContract?: string\n executionGuard: 'host' | 'contribution' | 'both'\n }\n }\n | {\n kind: 'data-table'\n details: {\n payload: 'column' | 'row-action' | 'bulk-action' | 'filter' | 'toolbar' | 'render'\n tableId: string\n executionGuard: 'host' | 'contribution' | 'both'\n }\n }\n | {\n kind: 'crud-form'\n details: {\n payload: 'render' | 'field' | 'lifecycle-handler'\n entityId: string\n fieldIds?: string[]\n groupIds?: string[]\n requestHeaderCapability: boolean\n }\n }\n | {\n kind: 'component-override'\n details: { handle: string; mode: 'replace' | 'wrapper' | 'props'; propsContract: string }\n }\n | {\n kind: 'response-enricher'\n details: {\n targetEntity: string\n surfaces: Array<'list' | 'detail'>\n timeoutMs: number\n fallback: 'none' | 'configured'\n critical: boolean\n cachePosture: 'record-pure' | 'rerun-on-list-cache-hit'\n queryEngine?: {\n engines: string[]\n applyOn: Array<'list' | 'detail'>\n activation: 'caller-opt-in'\n }\n }\n }\n | {\n kind: 'api-interceptor'\n details: {\n route: string\n methods: string[]\n phases: Array<'before' | 'after'>\n activation: 'crud-pipeline' | 'custom-route-bridge'\n timeoutMs: number\n failurePosture: 'fail-closed' | 'fallback'\n }\n }\n | {\n kind: 'command-interceptor'\n details: {\n targetCommand: string\n phases: Array<'before-execute' | 'after-execute' | 'before-undo' | 'after-undo'>\n }\n }\n | {\n kind: 'mutation-guard'\n details: {\n entityId: string\n operations: Array<'create' | 'update' | 'delete'>\n capabilities: Array<'block' | 'rewrite' | 'after-success'>\n optimisticLock: 'preserved'\n }\n }\n | {\n kind: 'entity-extension'\n details: {\n hostEntityId: string\n extensionEntityId: string\n linkId: string\n scopeContract: string\n orphanContract: string\n }\n }\n | {\n kind: 'subscriber'\n details: {\n event: string\n subscriberId: string\n persistent: boolean\n sync: boolean\n priority?: number\n }\n }\n | {\n kind: 'browser-reaction'\n details: {\n transports: Array<'client' | 'portal' | 'notification-effect'>\n hooks: string[]\n audienceScopeContract: string\n maxPayloadBytes?: number\n dedupWindowMs?: number\n /**\n * Statically declared handler id. Runtime keys `notifications.handlers`\n * overrides by it, so it is absent when the module declares no id (such a\n * handler cannot be overridden).\n */\n overrideKey?: string\n }\n }\n | {\n kind: 'specialized-registry'\n details: {\n registry: 'notification' | 'integration' | 'search' | 'vector' | 'ai' | 'payment' | 'shipping' | 'currency' | 'workflow'\n registryId: string\n specialistRoute: string\n }\n }\n | {\n kind: 'module-override'\n details: {\n domain: string\n key: string\n mode: 'disable-replace' | 'replace' | 'additive'\n }\n }\n)\n\nexport type ModuleExtensionUnresolvedFact = {\n key: string\n source: { path: string; symbol?: string }\n reason:\n | 'unclassified-binding'\n | 'unbound-declaration'\n | 'dynamic-without-pattern'\n | 'unresolved-first-party-target'\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["export const EXTENSION_HOST_FAMILIES = [\n 'generic',\n 'menu',\n 'data-table',\n 'crud-form',\n 'detail',\n 'portal-page',\n 'component-handle',\n 'entity',\n 'api-route',\n 'command',\n 'event',\n 'query-lifecycle',\n 'dashboard',\n 'notification',\n 'integration',\n 'specialized-registry',\n 'module-override',\n] as const\n\nexport type ExtensionHostFamily = (typeof EXTENSION_HOST_FAMILIES)[number]\n\nexport const EXTENSION_HOST_CAPABILITIES = [\n 'render-widget',\n 'headless-widget',\n 'menu-item',\n 'column-widget',\n 'row-action',\n 'bulk-action',\n 'filter-widget',\n 'toolbar-widget',\n 'field-widget',\n 'lifecycle-handler',\n 'component-replacement',\n 'response-enricher',\n 'query-enricher',\n 'api-interceptor',\n 'command-interceptor',\n 'mutation-guard',\n 'entity-extension',\n 'async-subscriber',\n 'sync-subscriber',\n 'browser-client',\n 'browser-portal',\n 'registry-contribution',\n 'module-override',\n] as const\n\nexport type ExtensionHostCapability = (typeof EXTENSION_HOST_CAPABILITIES)[number]\n\nexport const EXTENSION_HOST_ACTIVATIONS = ['always', 'host-opt-in', 'caller-opt-in', 'feature-gated'] as const\n\nexport type ExtensionHostActivation = (typeof EXTENSION_HOST_ACTIVATIONS)[number]\n\nexport type ExtensionPointPatternParameter = {\n source: string\n pattern?: string\n}\n\ntype ExtensionHostDeclarationBase = {\n source: string\n contextContract?: string\n dataContract?: string\n scopeContract?: string\n runtimeContract?: string\n activation?: ExtensionHostActivation\n aliases?: readonly string[]\n fallbacks?: readonly string[]\n}\n\ntype InjectionExtensionHostDeclarationBase = ExtensionHostDeclarationBase & {\n family: Exclude<ExtensionHostFamily, 'data-table' | 'crud-form' | 'component-handle'>\n supported: readonly ExtensionHostCapability[]\n}\n\nexport type InjectionExtensionHostDeclaration = InjectionExtensionHostDeclarationBase & (\n | {\n spotId: string\n pattern?: never\n parameters?: never\n }\n | {\n spotId?: never\n pattern: string\n parameters: Readonly<Record<string, ExtensionPointPatternParameter>>\n }\n)\n\nexport type DataTableExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'data-table'\n tableId: string\n baseSpotId?: string\n}\n\nexport type CrudFormExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'crud-form'\n entityId: string\n spotId?: string\n}\n\nexport type ComponentExtensionHostDeclaration = ExtensionHostDeclarationBase & {\n family: 'component-handle'\n componentId: string\n propsContract?: string\n}\n\nexport type ModuleExtensionHostDeclaration =\n | InjectionExtensionHostDeclaration\n | DataTableExtensionHostDeclaration\n | CrudFormExtensionHostDeclaration\n | ComponentExtensionHostDeclaration\n\nexport type ModuleExtensionPoints<\n TModuleId extends string = string,\n THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>> = Readonly<Record<string, ModuleExtensionHostDeclaration>>,\n> = {\n moduleId: TModuleId\n hosts: THosts\n}\n\nexport function defineModuleExtensionPoints<\n const TModuleId extends string,\n const THosts extends Readonly<Record<string, ModuleExtensionHostDeclaration>>,\n>(declaration: ModuleExtensionPoints<TModuleId, THosts>): ModuleExtensionPoints<TModuleId, THosts> {\n return Object.freeze({\n ...declaration,\n hosts: Object.freeze({ ...declaration.hosts }),\n })\n}\n\nexport function injectionExtensionHost<const TDeclaration extends InjectionExtensionHostDeclaration>(\n declaration: TDeclaration,\n): Readonly<TDeclaration> {\n const hasExactId = typeof declaration.spotId === 'string' && declaration.spotId.length > 0\n const hasPattern = typeof declaration.pattern === 'string' && declaration.pattern.length > 0\n if (hasExactId === hasPattern) {\n throw new Error('[internal] injection extension hosts require exactly one of spotId or pattern')\n }\n if (hasPattern && (!declaration.parameters || Object.keys(declaration.parameters).length === 0)) {\n throw new Error('[internal] patterned injection extension hosts require named parameters')\n }\n return Object.freeze({ ...declaration })\n}\n\nexport function dataTableExtensionHost<\n const TDeclaration extends Omit<DataTableExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'data-table' }> {\n return Object.freeze({ family: 'data-table', ...declaration })\n}\n\nexport function crudFormExtensionHost<\n const TDeclaration extends Omit<CrudFormExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'crud-form' }> {\n return Object.freeze({ family: 'crud-form', ...declaration })\n}\n\nexport function componentExtensionHost<\n const TDeclaration extends Omit<ComponentExtensionHostDeclaration, 'family'>,\n>(\n declaration: TDeclaration,\n): Readonly<TDeclaration & { family: 'component-handle' }> {\n return Object.freeze({ family: 'component-handle', ...declaration })\n}\n\nexport type BoundExtensionSurface = {\n key: string\n suffix: string | null\n capabilities: readonly ExtensionHostCapability[]\n bound: boolean\n phases?: readonly string[]\n operations?: readonly string[]\n}\n\nexport const DATA_TABLE_EXTENSION_SURFACES = [\n { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },\n { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: true },\n { key: 'toolbar', suffix: 'toolbar', capabilities: ['toolbar-widget'], bound: true },\n { key: 'searchTrailing', suffix: 'search-trailing', capabilities: ['render-widget'], bound: true },\n { key: 'columns', suffix: 'columns', capabilities: ['column-widget'], bound: true },\n { key: 'rowActions', suffix: 'row-actions', capabilities: ['row-action'], bound: true },\n { key: 'bulkActions', suffix: 'bulk-actions', capabilities: ['bulk-action'], bound: true },\n { key: 'filters', suffix: 'filters', capabilities: ['filter-widget'], bound: true },\n { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },\n { key: 'emptyState', suffix: 'empty-state', capabilities: ['render-widget'], bound: false },\n] as const satisfies readonly BoundExtensionSurface[]\n\nexport const DATA_TABLE_EXTENSION_SURFACE_KEYS = DATA_TABLE_EXTENSION_SURFACES.map((surface) => surface.key)\n\nexport const CRUD_FORM_EXTENSION_SURFACES = [\n { key: 'base', suffix: null, capabilities: ['render-widget', 'lifecycle-handler'], bound: true },\n { key: 'header', suffix: 'header', capabilities: ['render-widget'], bound: true },\n { key: 'fields', suffix: 'fields', capabilities: ['field-widget'], bound: true },\n { key: 'replacement', suffix: null, capabilities: ['component-replacement'], bound: true },\n { key: 'beforeFields', suffix: 'before-fields', capabilities: ['render-widget'], bound: false },\n { key: 'afterFields', suffix: 'after-fields', capabilities: ['render-widget'], bound: false },\n { key: 'footer', suffix: 'footer', capabilities: ['render-widget'], bound: false },\n { key: 'sidebar', suffix: 'sidebar', capabilities: ['render-widget'], bound: false },\n { key: 'group', suffix: 'group:{groupId}', capabilities: ['render-widget'], bound: false },\n { key: 'fieldBefore', suffix: 'field:{fieldId}:before', capabilities: ['render-widget'], bound: false },\n { key: 'fieldAfter', suffix: 'field:{fieldId}:after', capabilities: ['render-widget'], bound: false },\n] as const satisfies readonly BoundExtensionSurface[]\n\nexport const CRUD_FORM_EXTENSION_SURFACE_KEYS = CRUD_FORM_EXTENSION_SURFACES.map((surface) => surface.key)\n\nexport const CRUD_FORM_LIFECYCLE_PHASES = [\n 'transformValidation',\n 'transformDisplayData',\n 'onBeforeNavigate',\n 'onAppEvent',\n 'onVisibilityChange',\n 'onBeforeDelete',\n 'onDelete',\n 'onAfterDelete',\n 'onDeleteError',\n 'onFieldChange',\n 'transformFormData',\n 'onBeforeSave',\n 'onSave',\n 'onAfterSave',\n] as const\n\nexport const CRUD_FORM_OPERATIONS = ['create', 'update', 'delete'] as const\n\nexport function dataTableExtensionSpotId(tableId: string, suffix?: string): string {\n return suffix ? `data-table:${tableId}:${suffix}` : `data-table:${tableId}`\n}\n\nexport function crudFormExtensionSpotId(entityId: string, suffix?: string): string {\n return suffix ? `crud-form:${entityId}:${suffix}` : `crud-form:${entityId}`\n}\n\nexport function extensionSpotChildId(spotId: string, suffix: string): string {\n return `${spotId}:${suffix}`\n}\n\nexport function resolveExtensionPointPattern(\n pattern: string,\n parameters: Readonly<Record<string, string>>,\n): string {\n return pattern.replace(/\\{([^}]+)\\}/g, (token, parameterName: string) => parameters[parameterName] ?? token)\n}\n\n/**\n * Portable, structural copy of the CLI provenance source reference\n * (`ModuleFactSourceRef` in `packages/cli/.../module-facts.ts`). Redefined here\n * \u2014 rather than imported \u2014 so `@open-mercato/shared` keeps zero dependency on\n * `@open-mercato/cli`. Field names are kept byte-identical so the two are\n * assignable across the package boundary.\n */\nexport type ModuleFactSourceRef = {\n sourcePath: string\n exportName?: string\n line?: number\n}\n\n/** Portable, structural copy of the CLI provenance fact reference. */\nexport type ModuleFactRef = {\n factSection: string\n factKey: string\n}\n\nexport const MODULE_EXTENSION_CONTRIBUTION_KINDS = [\n 'widget',\n 'data-table',\n 'crud-form',\n 'component-override',\n 'response-enricher',\n 'api-interceptor',\n 'command-interceptor',\n 'mutation-guard',\n 'entity-extension',\n 'subscriber',\n 'browser-reaction',\n 'specialized-registry',\n 'module-override',\n] as const\n\nexport type ModuleExtensionContributionKind = (typeof MODULE_EXTENSION_CONTRIBUTION_KINDS)[number]\n\nexport const MODULE_EXTENSION_ACTIVATION_KINDS = [\n 'crud-response-enricher',\n 'query-enricher',\n 'mutation-guard',\n 'api-interceptor-bridge',\n 'command-interceptor-bridge',\n 'widget-injection-consumer',\n 'component-extension-consumer',\n 'dashboard-host-consumer',\n] as const\n\nexport type ModuleExtensionActivationKind = (typeof MODULE_EXTENSION_ACTIVATION_KINDS)[number]\n\nexport const MODULE_EXTENSION_TARGET_KINDS = [\n 'module',\n 'entity',\n 'api-route',\n 'command',\n 'widget-spot',\n 'component',\n 'event',\n 'notification',\n 'wildcard',\n] as const\n\nexport type ModuleExtensionTargetKind = (typeof MODULE_EXTENSION_TARGET_KINDS)[number]\n\nexport type ModuleExtensionTargetRef = {\n kind: ModuleExtensionTargetKind\n id: string\n moduleId?: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n}\n\nexport type ModuleExtensionActivation = {\n id: string\n kind: ModuleExtensionActivationKind\n host: ModuleExtensionTargetRef\n contributionKinds: ModuleExtensionContributionKind[]\n phases?: string[]\n /**\n * Mutation operations the call site guards, when statically declared. A\n * contribution declaring only other operations is not bound by this activation.\n */\n operations?: string[]\n source: ModuleFactSourceRef\n bridge?: ModuleFactRef\n}\n\nexport const MODULE_EXTENSION_RESOLUTIONS = [\n 'bound',\n 'capability-only',\n 'optional-target-missing',\n 'wildcard',\n 'unresolved',\n] as const\n\nexport type ModuleExtensionResolution = (typeof MODULE_EXTENSION_RESOLUTIONS)[number]\n\nexport type ModuleIncomingExtensionRef = {\n contributionId: string\n contributionKind: ModuleExtensionContributionKind\n contributorModuleId: string\n target: ModuleExtensionTargetRef\n activationId?: string\n resolution: ModuleExtensionResolution\n source: ModuleFactSourceRef\n}\n\nexport type ModuleContributionResolution = {\n contributionId: string\n target: ModuleExtensionTargetRef\n resolution: ModuleExtensionResolution\n activationIds: string[]\n}\n\n/**\n * Additive, optional bidirectional-topology fields layered on top of the\n * existing extension-surface facts. Every field is optional so legacy consumers\n * and pre-existing generated output remain byte-compatible.\n */\nexport type ModuleExtensionSurfaceFactsAdditions = {\n activations?: ModuleExtensionActivation[]\n incoming?: ModuleIncomingExtensionRef[]\n contributionResolutions?: ModuleContributionResolution[]\n}\n\nexport type ModuleExtensionSurfaceFacts = {\n hosts: ModuleExtensionHostFact[]\n contributions: ModuleExtensionContributionFact[]\n unresolved: ModuleExtensionUnresolvedFact[]\n} & ModuleExtensionSurfaceFactsAdditions\n\nexport const MODULE_EXTENSION_HOST_RESOLUTIONS = ['exact', 'pattern', 'framework', 'fact-ref'] as const\n\nexport type ModuleExtensionHostResolution = (typeof MODULE_EXTENSION_HOST_RESOLUTIONS)[number]\n\nexport type ModuleExtensionHostFact = {\n key: string\n id: string\n resolution: ModuleExtensionHostResolution\n family: ExtensionHostFamily\n ownerModule: string\n capabilities: ExtensionHostCapability[]\n phases?: string[]\n operations?: string[]\n contextContract?: string\n dataContract?: string\n scopeContract?: string\n runtimeContract?: string\n activation?: ExtensionHostActivation\n bound: boolean\n stability: 'frozen' | 'stable'\n source:\n | { kind: 'declaration'; path: string; symbol: string }\n | { kind: 'fact-ref'; factSection: string; factKey: string }\n | { kind: 'framework'; path: string; symbol: string }\n aliases?: string[]\n patternParameters?: Record<string, ExtensionPointPatternParameter>\n fallbacks?: string[]\n}\n\nexport const MODULE_EXTENSION_TARGET_RESOLUTIONS = [\n 'exact',\n 'pattern',\n 'framework',\n 'fact-ref',\n 'optional-external',\n 'unresolved',\n] as const\n\nexport type ModuleExtensionTargetResolution = (typeof MODULE_EXTENSION_TARGET_RESOLUTIONS)[number]\n\nexport type ModuleExtensionTargetFact = {\n id: string\n resolution: ModuleExtensionTargetResolution\n factRef?: { factSection: string; factKey: string }\n optionalOwnerPackage?: string\n}\n\nexport type ModuleExtensionContributionBase = {\n id: string\n targets: ModuleExtensionTargetFact[]\n phases?: string[]\n operations?: string[]\n features?: string[]\n scopeContract: string\n activation?: ExtensionHostActivation\n placement?: { relativeTo?: string; position?: 'first' | 'last' | 'before' | 'after'; priority?: number }\n roundTripId?: string\n override?: { domain: string; key: string; mode: 'disable-replace' | 'replace' | 'additive' }\n source: { path: string; symbol?: string }\n}\n\nexport const MODULE_SPECIALIZED_REGISTRIES = [\n 'notification',\n 'integration',\n 'search',\n 'vector',\n 'ai',\n 'payment',\n 'shipping',\n 'currency',\n 'workflow',\n] as const\n\nexport type ModuleSpecializedRegistry = (typeof MODULE_SPECIALIZED_REGISTRIES)[number]\n\nexport const COMPONENT_OVERRIDE_MODES = ['replace', 'wrapper', 'props'] as const\n\nexport type ModuleComponentOverrideMode = (typeof COMPONENT_OVERRIDE_MODES)[number]\n\nexport type ModuleExtensionContributionFact = ModuleExtensionContributionBase & (\n | {\n kind: 'widget'\n details: {\n payload: 'render' | 'headless' | 'menu' | 'dashboard' | 'notification' | 'integration'\n registryKey: string\n itemIds?: string[]\n labelKeys?: string[]\n contextContract?: string\n dataContract?: string\n executionGuard: 'host' | 'contribution' | 'both'\n }\n }\n | {\n kind: 'data-table'\n details: {\n payload: 'column' | 'row-action' | 'bulk-action' | 'filter' | 'toolbar' | 'render'\n tableId: string\n executionGuard: 'host' | 'contribution' | 'both'\n }\n }\n | {\n kind: 'crud-form'\n details: {\n payload: 'render' | 'field' | 'lifecycle-handler'\n entityId: string\n fieldIds?: string[]\n groupIds?: string[]\n requestHeaderCapability: boolean\n }\n }\n | {\n kind: 'component-override'\n details: { handle: string; mode: ModuleComponentOverrideMode; propsContract: string }\n }\n | {\n kind: 'response-enricher'\n details: {\n targetEntity: string\n surfaces: Array<'list' | 'detail'>\n timeoutMs: number\n fallback: 'none' | 'configured'\n critical: boolean\n cachePosture: 'record-pure' | 'rerun-on-list-cache-hit'\n queryEngine?: {\n engines: string[]\n applyOn: Array<'list' | 'detail'>\n activation: 'caller-opt-in'\n }\n }\n }\n | {\n kind: 'api-interceptor'\n details: {\n route: string\n methods: string[]\n phases: Array<'before' | 'after'>\n activation: 'crud-pipeline' | 'custom-route-bridge'\n timeoutMs: number\n failurePosture: 'fail-closed' | 'fallback'\n }\n }\n | {\n kind: 'command-interceptor'\n details: {\n targetCommand: string\n phases: Array<'before-execute' | 'after-execute' | 'before-undo' | 'after-undo'>\n }\n }\n | {\n kind: 'mutation-guard'\n details: {\n entityId: string\n operations: Array<'create' | 'update' | 'delete'>\n capabilities: Array<'block' | 'rewrite' | 'after-success'>\n optimisticLock: 'preserved'\n }\n }\n | {\n kind: 'entity-extension'\n details: {\n hostEntityId: string\n extensionEntityId: string\n linkId: string\n scopeContract: string\n orphanContract: string\n }\n }\n | {\n kind: 'subscriber'\n details: {\n event: string\n subscriberId: string\n persistent: boolean\n sync: boolean\n priority?: number\n }\n }\n | {\n kind: 'browser-reaction'\n details: {\n transports: Array<'client' | 'portal' | 'notification-effect'>\n hooks: string[]\n audienceScopeContract: string\n maxPayloadBytes?: number\n dedupWindowMs?: number\n /**\n * Statically declared handler id. Runtime keys `notifications.handlers`\n * overrides by it, so it is absent when the module declares no id (such a\n * handler cannot be overridden).\n */\n overrideKey?: string\n }\n }\n | {\n kind: 'specialized-registry'\n details: {\n registry: ModuleSpecializedRegistry\n registryId: string\n specialistRoute: string\n }\n }\n | {\n kind: 'module-override'\n details: {\n domain: string\n key: string\n mode: 'disable-replace' | 'replace' | 'additive'\n }\n }\n)\n\nexport const MODULE_EXTENSION_UNRESOLVED_REASONS = [\n 'unclassified-binding',\n 'unbound-declaration',\n 'dynamic-without-pattern',\n 'unresolved-first-party-target',\n] as const\n\nexport type ModuleExtensionUnresolvedReason = (typeof MODULE_EXTENSION_UNRESOLVED_REASONS)[number]\n\nexport type ModuleExtensionUnresolvedFact = {\n key: string\n source: { path: string; symbol?: string }\n reason: ModuleExtensionUnresolvedReason\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.6930.1.1e5976efc3",
3
+ "version": "0.6.8-develop.6940.1.177ea30c6e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -105,7 +105,7 @@
105
105
  "@mikro-orm/core": "^7.1.8",
106
106
  "@mikro-orm/decorators": "^7.1.8",
107
107
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.8-develop.6930.1.1e5976efc3",
108
+ "@open-mercato/cache": "0.6.8-develop.6940.1.177ea30c6e",
109
109
  "@types/sanitize-html": "^2.16.1",
110
110
  "dotenv": "^17.4.2",
111
111
  "pino": "^10.3.1",
@@ -185,3 +185,64 @@ describe('auth server integrity checks', () => {
185
185
  await expect(resolveAuthFromRequestDetailed(request)).resolves.toEqual({ auth: null, status: 'missing' })
186
186
  })
187
187
  })
188
+
189
+ describe('super-admin tenant cookie override', () => {
190
+ const ACTOR_TENANT = '22222222-2222-4222-8222-222222222222'
191
+ const OTHER_TENANT = '44444444-4444-4444-8444-444444444444'
192
+
193
+ const superAdminAuth = {
194
+ sub: '11111111-1111-4111-8111-111111111111',
195
+ tenantId: ACTOR_TENANT,
196
+ orgId: '33333333-3333-4333-8333-333333333333',
197
+ roles: ['superadmin'],
198
+ isSuperAdmin: true,
199
+ }
200
+
201
+ beforeEach(() => {
202
+ jest.clearAllMocks()
203
+ cookieStore.get.mockReset()
204
+ createRequestContainer.mockResolvedValue({
205
+ resolve: (name: string) => (name === 'em' ? em : null),
206
+ })
207
+ verifyJwt.mockReturnValue(superAdminAuth)
208
+ resolveCanonicalStaffAuthContext.mockResolvedValue(superAdminAuth)
209
+ })
210
+
211
+ async function authFor(cookie: string) {
212
+ const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
213
+ return getAuthFromRequest(new Request('https://example.test/api/test', { headers: { cookie } }))
214
+ }
215
+
216
+ it.each([
217
+ ['blank', 'om_selected_tenant='],
218
+ ['whitespace-only', 'om_selected_tenant=%20%20'],
219
+ ])('treats a %s tenant cookie as no selection and keeps the tenant from the token', async (_label, tenantCookie) => {
220
+ const auth = await authFor(`auth_token=jwt-token; ${tenantCookie}`)
221
+
222
+ expect(auth?.tenantId).toBe(ACTOR_TENANT)
223
+ expect(auth).not.toHaveProperty('actorTenantId')
224
+ })
225
+
226
+ it('still applies a concrete tenant selection and preserves the actor tenant', async () => {
227
+ const auth = await authFor(`auth_token=jwt-token; om_selected_tenant=${OTHER_TENANT}`)
228
+
229
+ expect(auth?.tenantId).toBe(OTHER_TENANT)
230
+ expect((auth as { actorTenantId?: string | null }).actorTenantId).toBe(ACTOR_TENANT)
231
+ })
232
+
233
+ it('leaves the organization override working when the tenant cookie is blank', async () => {
234
+ const auth = await authFor('auth_token=jwt-token; om_selected_tenant=; om_selected_org=__all__')
235
+
236
+ expect(auth?.tenantId).toBe(ACTOR_TENANT)
237
+ expect(auth?.orgId).toBeNull()
238
+ expect((auth as { actorOrgId?: string | null }).actorOrgId).toBe(superAdminAuth.orgId)
239
+ })
240
+
241
+ it('leaves a non-super-admin session untouched by a blank tenant cookie', async () => {
242
+ const staffAuth = { ...superAdminAuth, roles: ['employee'], isSuperAdmin: false }
243
+ verifyJwt.mockReturnValue(staffAuth)
244
+ resolveCanonicalStaffAuthContext.mockResolvedValue(staffAuth)
245
+
246
+ await expect(authFor('auth_token=jwt-token; om_selected_tenant=')).resolves.toEqual(staffAuth)
247
+ })
248
+ })
@@ -98,12 +98,22 @@ function readCookieFromHeader(header: string | null | undefined, name: string):
98
98
  return undefined
99
99
  }
100
100
 
101
+ /**
102
+ * A blank `om_selected_tenant` is "no selection", not a deliberate "no tenant".
103
+ *
104
+ * Unlike the organization cookie there is no all-tenants sentinel to express, and the tenant
105
+ * selector renders with `includeEmptyOption={false}`, so no UI produces a blank value. Treating it
106
+ * as an applied override nulled `tenantId` for the whole super-admin session, which is what turned
107
+ * a cosmetic client bug into `tenantId ?? ''` reaching a NOT NULL uuid column. Reporting the
108
+ * override as not applied instead keeps the tenant carried in the token and remediates an
109
+ * already-poisoned browser on its next request, whatever wrote the cookie.
110
+ */
101
111
  function resolveTenantOverride(raw: string | undefined): CookieOverride {
102
112
  if (raw === undefined) return { applied: false, value: null }
103
113
  const decoded = decodeCookieValue(raw)
104
- if (!decoded) return { applied: true, value: null }
114
+ if (!decoded) return { applied: false, value: null }
105
115
  const trimmed = decoded.trim()
106
- if (!trimmed) return { applied: true, value: null }
116
+ if (!trimmed) return { applied: false, value: null }
107
117
  return { applied: true, value: trimmed }
108
118
  }
109
119
 
@@ -358,20 +358,54 @@ type CompileAndImportOptions = {
358
358
  }
359
359
 
360
360
  /**
361
- * Compile a TypeScript file to JavaScript using esbuild bundler.
362
- * This bundles the file and all its dependencies, handling JSON imports properly.
363
- * The compiled file is written next to the source file with a .mjs extension unless
364
- * `outFile` says otherwise.
361
+ * Options for `compileAppSourceFile`.
362
+ *
363
+ * `appRoot` anchors the tsconfig, the `@/` alias resolution and the dependency
364
+ * cache; `outFile` is the absolute path of the artifact to write. `format`
365
+ * selects the module system of that artifact — `'cjs'` exists for the Jest
366
+ * runtime, which cannot `import()` an ESM sibling.
365
367
  */
366
- async function compileAndImport(
368
+ export type CompileAppSourceOptions = {
369
+ appRoot: string
370
+ outFile: string
371
+ format?: 'esm' | 'cjs'
372
+ }
373
+
374
+ /**
375
+ * Compile one app-owned TypeScript source and its relative import graph into a
376
+ * single JavaScript artifact, leaving every package import external.
377
+ *
378
+ * This is the only supported way to load app source (`apps/<app>/src/**`,
379
+ * `.mercato/generated/**`) from a plain Node process. Those files are never
380
+ * compiled to `dist`, and Node's own type stripping cannot load them: it
381
+ * requires explicit file extensions on relative specifiers and rejects the
382
+ * decorator and enum syntax the entities and DI files use.
383
+ *
384
+ * The artifact is cached against the content of the entry, its whole bundled
385
+ * dependency graph, and the tsconfig chain, so an edit anywhere in the graph
386
+ * invalidates it.
387
+ *
388
+ * The build runs inside the shared esbuild lifecycle. Callers outside a
389
+ * bootstrap load — the generated-registry loader compiling an `@app` module —
390
+ * would otherwise hold a build on a service another scope is entitled to
391
+ * `stop()`, and would leave the helper process running afterwards. Nesting is
392
+ * safe: the scope only releases the service when the last participant exits.
393
+ */
394
+ export async function compileAppSourceFile(
367
395
  tsPath: string,
368
- options: CompileAndImportOptions = {},
369
- ): Promise<Record<string, unknown>> {
370
- const allowRecovery = options.allowRecovery ?? true
371
- const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
372
- const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
396
+ options: CompileAppSourceOptions,
397
+ ): Promise<string> {
398
+ return withEsbuildLifecycle(() => compileAppSourceFileWithActiveEsbuild(tsPath, options))
399
+ }
400
+
401
+ async function compileAppSourceFileWithActiveEsbuild(
402
+ tsPath: string,
403
+ options: CompileAppSourceOptions,
404
+ ): Promise<string> {
405
+ const { appRoot, outFile } = options
406
+ const format = options.format ?? 'esm'
373
407
  const appTsconfig = path.join(appRoot, 'tsconfig.json')
374
- const metadataPath = cacheMetadataPath(jsPath)
408
+ const metadataPath = cacheMetadataPath(outFile)
375
409
 
376
410
  const tsExists = fs.existsSync(tsPath)
377
411
  const tsconfigExists = fs.existsSync(appTsconfig)
@@ -385,39 +419,59 @@ async function compileAndImport(
385
419
 
386
420
  const tsconfigPaths = collectTsconfigPaths(appTsconfig)
387
421
  const expectedInputHash = cacheInputHash(tsPath, appRoot, tsconfigPaths)
388
- const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
389
-
390
- if (needsCompile) {
391
- fs.mkdirSync(path.dirname(jsPath), { recursive: true })
392
- // Dynamically import esbuild only when needed
393
- const esbuild = await getEsbuildRuntime()
394
-
395
- // Use esbuild.build with bundling to handle JSON imports
396
- const result = await esbuild.build({
397
- entryPoints: [tsPath],
398
- outfile: jsPath,
399
- absWorkingDir: appRoot,
400
- bundle: true,
401
- metafile: true,
402
- format: 'esm',
403
- platform: 'node',
404
- target: 'node18',
405
- tsconfig: appTsconfig,
406
- plugins: createCliBundlePlugins(appRoot),
407
- // Allow JSON imports
408
- loader: { '.json': 'json' },
409
- })
410
- const metadata: DynamicLoaderCacheMetadata = {
411
- version: DYNAMIC_LOADER_CACHE_VERSION,
412
- inputHash: expectedInputHash,
413
- outputHash: contentHash(fs.readFileSync(jsPath)),
414
- dependencies: {
415
- ...collectDependencyHashes(appRoot, result.metafile.inputs),
416
- ...hashFilesRelativeTo(appRoot, tsconfigPaths),
417
- },
418
- }
419
- fs.writeFileSync(metadataPath, JSON.stringify(metadata))
422
+
423
+ if (cacheIsValid(appRoot, outFile, metadataPath, expectedInputHash)) {
424
+ return outFile
425
+ }
426
+
427
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
428
+ // Dynamically import esbuild only when needed
429
+ const esbuild = await getEsbuildRuntime()
430
+
431
+ // Use esbuild.build with bundling to handle JSON imports
432
+ const result = await esbuild.build({
433
+ entryPoints: [tsPath],
434
+ outfile: outFile,
435
+ absWorkingDir: appRoot,
436
+ bundle: true,
437
+ metafile: true,
438
+ format,
439
+ platform: 'node',
440
+ target: 'node18',
441
+ tsconfig: appTsconfig,
442
+ plugins: createCliBundlePlugins(appRoot),
443
+ // Allow JSON imports
444
+ loader: { '.json': 'json' },
445
+ })
446
+ const metadata: DynamicLoaderCacheMetadata = {
447
+ version: DYNAMIC_LOADER_CACHE_VERSION,
448
+ inputHash: expectedInputHash,
449
+ outputHash: contentHash(fs.readFileSync(outFile)),
450
+ dependencies: {
451
+ ...collectDependencyHashes(appRoot, result.metafile.inputs),
452
+ ...hashFilesRelativeTo(appRoot, tsconfigPaths),
453
+ },
420
454
  }
455
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata))
456
+
457
+ return outFile
458
+ }
459
+
460
+ /**
461
+ * Compile a TypeScript file to JavaScript using esbuild bundler.
462
+ * This bundles the file and all its dependencies, handling JSON imports properly.
463
+ * The compiled file is written next to the source file with a .mjs extension unless
464
+ * `outFile` says otherwise.
465
+ */
466
+ async function compileAndImport(
467
+ tsPath: string,
468
+ options: CompileAndImportOptions = {},
469
+ ): Promise<Record<string, unknown>> {
470
+ const allowRecovery = options.allowRecovery ?? true
471
+ const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
472
+ const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
473
+
474
+ await compileAppSourceFile(tsPath, { appRoot, outFile: jsPath })
421
475
 
422
476
  // Import the compiled JavaScript
423
477
  try {
@@ -585,6 +585,45 @@ describe('CRUD Factory', () => {
585
585
  expect(ids).toContain(otherTenant.id)
586
586
  })
587
587
 
588
+ it('GET resolves function-form CSV headers and rows per request', async () => {
589
+ // Regression coverage for the additive `(query, ctx)` form of `ListConfig.csv`.
590
+ // A route whose export columns depend on per-request discovery (custom-field keys,
591
+ // for example) must resolve them from the request context, never from module-level
592
+ // state that the previous request left behind.
593
+ const perRequestColumns = new WeakMap<object, string>()
594
+ const dynamicCsvRoute = makeCrudRoute({
595
+ metadata: { GET: { requireAuth: true } },
596
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
597
+ indexer: { entityType: 'example.todo' },
598
+ list: {
599
+ schema: querySchema,
600
+ entityId: 'example.todo',
601
+ fields: ['id', 'title', 'is_done'],
602
+ sortFieldMap: { id: 'id' },
603
+ buildFilters: () => ({} as any),
604
+ transformItem: (item: any) => ({ id: item.id, title: item.title }),
605
+ allowCsv: true,
606
+ csv: {
607
+ headers: (_query, ctx) => ['id', perRequestColumns.get(ctx) ?? 'fallback'],
608
+ row: (item: any, ctx) => [item.id, `${perRequestColumns.get(ctx) ?? 'fallback'}:${item.title}`],
609
+ filename: 'dynamic.csv',
610
+ },
611
+ },
612
+ hooks: {
613
+ beforeList: (_query, ctx) => {
614
+ const column = new URL(ctx.request!.url).searchParams.get('column')
615
+ perRequestColumns.set(ctx, column ?? 'fallback')
616
+ },
617
+ },
618
+ })
619
+
620
+ const first = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=alpha'))
621
+ const second = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=beta'))
622
+
623
+ expect((await first.text()).split('\n')[0]).toBe('id,alpha')
624
+ expect((await second.text()).split('\n')[0]).toBe('id,beta')
625
+ })
626
+
588
627
  it('GET returns CSV when format=csv', async () => {
589
628
  const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc&format=csv'))
590
629
  expect(res.headers.get('content-type')).toContain('text/csv')