     1	export const MultiHomePlugin = {
     2	  name: 'multihome',
     3	  dependencies: ['rest-api'],
     4	
     5	  install ({ api, addHook, vars, helpers, log, scopes, pluginOptions }) {
     6	    if (!api.knex?.instance) {
     7	      throw new Error('Multihome plugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex)')
     8	    }
     9	
    10	    // Get configuration - hooked-api namespaces options by plugin name
    11	    const multihomeOptions = pluginOptions || {}
    12	
    13	    // Store configuration in vars (data only)
    14	    vars.multihome = {
    15	      field: multihomeOptions.field || 'multihome_id',
    16	      excludeResources: multihomeOptions.excludeResources || ['system_migrations', 'system_logs'],
    17	      requireAuth: multihomeOptions.requireAuth !== undefined ? multihomeOptions.requireAuth : true,
    18	      allowMissing: multihomeOptions.allowMissing || false
    19	    }
    20	
    21	    const resolveStorageAdapter = (scopeName, hookContext = {}) => {
    22	      if (!scopeName) return null
    23	      if (hookContext.storageAdapter) return hookContext.storageAdapter
    24	      if (hookContext.knexQuery?.storageAdapter) return hookContext.knexQuery.storageAdapter
    25	      if (helpers.getStorageAdapter) {
    26	        return helpers.getStorageAdapter(scopeName)
    27	      }
    28	      return null
    29	    }
    30	
    31	    const translateColumnReference = ({ field, tableName, scopeName, hookContext }) => {
    32	      const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
    33	      const translated = storageAdapter?.translateColumn?.(field) ?? field
    34	      if (tableName) {
    35	        return `${tableName}.${translated}`
    36	      }
    37	      return translated
    38	    }
    39	
    40	    const translateFilterValue = ({ field, value, scopeName, hookContext }) => {
    41	      const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
    42	      if (!storageAdapter?.translateFilterValue) return value
    43	      return storageAdapter.translateFilterValue(field, value)
    44	    }
    45	
    46	    // Default extractor function
    47	    function defaultSubdomainExtractor (request) {
    48	      // Extract from subdomain (e.g., 'mobily' from 'mobily.app.com')
    49	      const host = request.headers?.host || request.hostname || ''
    50	      const subdomain = host.split('.')[0]
    51	
    52	      // Don't use 'www' or 'api' as tenant IDs
    53	      if (subdomain && !['www', 'api', 'app'].includes(subdomain)) {
    54	        return subdomain
    55	      }
    56	
    57	      // Fallback to header
    58	      return request.headers?.['x-multihome-id'] || null
    59	    }
    60	
    61	    // Store extractor function in helpers
    62	    helpers.extractMultihomeId = multihomeOptions.extractor || defaultSubdomainExtractor
    63	
    64	    // Hook into transport layer to extract multihome_id
    65	    addHook('transport:request', 'extract-multihome-id', {}, async ({ context, request, helpers }) => {
    66	      // Extract multihome_id using configured extractor
    67	      const multihomeId = helpers.extractMultihomeId(request)
    68	
    69	      if (multihomeId) {
    70	        // Store in auth context (even if no user auth)
    71	        context.auth = context.auth || {}
    72	        context.auth.multihome_id = multihomeId
    73	
    74	        log.debug('Multihome ID extracted', {
    75	          multihome_id: multihomeId,
    76	          source: request.headers?.host
    77	        })
    78	      } else if (vars.multihome.requireAuth) {
    79	        log.warn('No multihome ID found in request', {
    80	          host: request.headers?.host,
    81	          headers: Object.keys(request.headers || {})
    82	        })
    83	      }
    84	    })
    85	
    86	    // Validate that resources have multihome field when added
    87	    addHook('scope:added', 'validate-multihome-field', {}, ({ context, vars }) => {
    88	      const { scopeName, scopeOptions } = context
    89	
    90	      // Skip excluded resources
    91	      if (vars.multihome.excludeResources.includes(scopeName)) {
    92	        log.debug(`Resource ${scopeName} excluded from multihome validation`)
    93	        return
    94	      }
    95	
    96	      // Check if schema has multihome field
    97	      const schema = scopeOptions.schema
    98	      if (schema && !schema[vars.multihome.field]) {
    99	        if (vars.multihome.allowMissing) {
   100	          log.warn(`Resource ${scopeName} missing ${vars.multihome.field} field - multihome filtering disabled for this 
   101	  resource`)
   102	        } else {
   103	          throw new Error(
   104	              `Resource '${scopeName}' must have '${vars.multihome.field}' field in schema for multi-tenancy`
   105	          )
   106	        }
   107	      }
   108	    })
   109	
   110	    // Main filtering hook - adds WHERE clause to all queries
   111	    log.debug('Registering multihome filter hook')
   112	    addHook('knexQueryFiltering', 'multihome-filter', {}, async ({ context, vars }) => {
   113	      // Get query info from context
   114	      const { query, tableName, scopeName } = context.knexQuery
   115	
   116	      // Skip excluded resources first
   117	      if (vars.multihome.excludeResources.includes(scopeName)) {
   118	        log.trace(`Skipping multihome filter for excluded resource: ${scopeName}`)
   119	        return
   120	      }
   121	
   122	      // Skip if no multihome context
   123	      if (!context.auth?.multihome_id) {
   124	        if (vars.multihome.requireAuth) {
   125	          throw new Error('No multihome context available - cannot execute query')
   126	        }
   127	        return
   128	      }
   129	
   130	      log.debug('Applying multihome tenant filter', {
   131	        scopeName,
   132	        tableName,
   133	        tenant: context.auth.multihome_id,
   134	      })
   135	
   136	      // Check if this resource has multihome field
   137	      const scope = scopes[scopeName]
   138	      const hasMultihomeField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.multihome.field]
   139	
   140	      if (!hasMultihomeField) {
   141	        if (vars.multihome.allowMissing) {
   142	          log.trace(`Resource ${scopeName} has no ${vars.multihome.field} field - skipping filter`)
   143	          return
   144	        } else {
   145	          throw new Error(`Resource ${scopeName} missing required ${vars.multihome.field} field`)
   146	        }
   147	      }
   148	
   149	      const storageAdapter = resolveStorageAdapter(scopeName, context)
   150	      if (storageAdapter && !context.storageAdapter) {
   151	        context.storageAdapter = storageAdapter
   152	      }
   153	
   154	      const columnRef = translateColumnReference({
   155	        field: vars.multihome.field,
   156	        tableName,
   157	        scopeName,
   158	        hookContext: context,
   159	      })
   160	
   161	      const tenantValue = translateFilterValue({
   162	        field: vars.multihome.field,
   163	        value: context.auth.multihome_id,
   164	        scopeName,
   165	        hookContext: context,
   166	      })
   167	
   168	      if (tenantValue === null) {
   169	        query.whereNull(columnRef)
   170	      } else {
   171	        query.where(columnRef, tenantValue)
   172	      }
   173	
   174	      log.trace('Added multihome filter', {
   175	        scopeName,
   176	        tableName,
   177	        multihome_id: context.auth.multihome_id
   178	      })
   179	    })
   180	
   181	    // Set multihome_id on new records
   182	    addHook('beforeSchemaValidate', 'set-multihome-id', {}, async ({ context, scopeName, vars }) => {
   183	      // Skip excluded resources first
   184	      if (vars.multihome.excludeResources.includes(scopeName)) {
   185	        return
   186	      }
   187	
   188	      // Skip if no multihome context
   189	      if (!context.auth?.multihome_id) {
   190	        if (vars.multihome.requireAuth) {
   191	          throw new Error('Cannot create record without multihome context')
   192	        }
   193	        return
   194	      }
   195	
   196	      // Check if this resource has multihome field
   197	      const scope = scopes[scopeName]
   198	      const hasMultihomeField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.multihome.field]
   199	
   200	      if (!hasMultihomeField) {
   201	        if (!vars.multihome.allowMissing) {
   202	          throw new Error(`Resource ${scopeName} missing required ${vars.multihome.field} field`)
   203	        }
   204	        return
   205	      }
   206	
   207	      // For POST, always set multihome_id
   208	      if (context.method === 'post') {
   209	        context.inputRecord.data.attributes = context.inputRecord.data.attributes || {}
   210	        context.inputRecord.data.attributes[vars.multihome.field] = context.auth.multihome_id
   211	
   212	        log.debug('Set multihome_id on new record', {
   213	          scopeName,
   214	          multihome_id: context.auth.multihome_id
   215	        })
   216	      }
   217	
   218	      // For PUT/PATCH, validate multihome_id if provided
   219	      if ((context.method === 'put' || context.method === 'patch') &&
   220	            context.inputRecord.data.attributes?.[vars.multihome.field]) {
   221	        const providedId = context.inputRecord.data.attributes[vars.multihome.field]
   222	        if (providedId !== context.auth.multihome_id) {
   223	          throw new Error(
   224	              `Cannot set ${vars.multihome.field} to '${providedId}' - must match current context '${context.auth.multihome_id}'`
   225	          )
   226	        }
   227	      }
   228	    })
   229	
   230	    // Add checkPermissions hook to enforce tenant isolation
   231	    // This is the single source of truth for single-record access control
   232	    addHook('checkPermissions', 'multihome-check-permissions', {}, async ({ context, scopeName }) => {
   233	      // Extract the needed values from originalContext
   234	      const auth = context.originalContext?.auth
   235	      const minimalRecord = context.originalContext?.minimalRecord
   236	      const id = context.originalContext?.id
   237	
   238	      // Skip excluded resources
   239	      if (vars.multihome.excludeResources.includes(scopeName)) {
   240	        return
   241	      }
   242	
   243	      // Skip if no multihome context
   244	      if (!auth?.multihome_id) {
   245	        if (vars.multihome.requireAuth) {
   246	          throw new Error('No multihome context available')
   247	        }
   248	        return
   249	      }
   250	
   251	      // Get the scope to check if it has multihome field
   252	      const scope = scopes[scopeName]
   253	      const hasMultihomeField = scope?.vars?.schemaInfo?.schemaStructure?.[vars.multihome.field]
   254	
   255	      if (!hasMultihomeField) {
   256	        return // Resource doesn't support multihome
   257	      }
   258	
   259	      // For operations on existing records, verify tenant ownership
   260	      if (minimalRecord) {
   261	        // minimalRecord is in JSON:API format, so tenant_id is in attributes
   262	        const recordTenant = minimalRecord.attributes?.[vars.multihome.field]
   263	        const userTenant = auth.multihome_id
   264	
   265	        if (recordTenant !== userTenant) {
   266	          log.error('Multihome permission violation', {
   267	            scopeName,
   268	            recordId: id,
   269	            recordTenant,
   270	            userTenant,
   271	            method: context.method
   272	          })
   273	
   274	          // Return 404 for GET to prevent information leakage
   275	          // Return 403 for other operations
   276	          if (context.method === 'get') {
   277	            const error = new Error('Resource not found')
   278	            error.code = 'REST_API_RESOURCE'
   279	            throw error
   280	          } else {
   281	            const error = new Error('Access denied - insufficient permissions')
   282	            error.code = 'REST_API_FORBIDDEN'
   283	            throw error
   284	          }
   285	        }
   286	      }
   287	    })
   288	
   289	    // Note: PUT, PATCH, and DELETE operations will fail naturally if the record
   290	    // doesn't exist due to tenant filtering in the GET operation that precedes them
   291	
   292	    // Add API method to get current multihome context
   293	    api.multihome = {
   294	      getCurrentTenant: () => {
   295	        // This would need to be called within a request context
   296	        // Real implementation would need access to current request context
   297	        return null
   298	      },
   299	
   300	      // Runtime configuration helper (for debugging/testing)
   301	      getConfig: () => ({
   302	        ...vars.multihome,
   303	        hasCustomExtractor: helpers.extractMultihomeId !== defaultSubdomainExtractor
   304	      })
   305	    }
   306	
   307	    log.info('MultiHome plugin installed', {
   308	      field: vars.multihome.field,
   309	      excludedResources: vars.multihome.excludeResources,
   310	      requireAuth: vars.multihome.requireAuth,
   311	      hasCustomExtractor: helpers.extractMultihomeId !== defaultSubdomainExtractor
   312	    })
   313	  }
   314	}
