Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
497 lines
25 KiB
Python
497 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from govoplan_core.core.access import (
|
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
|
)
|
|
from govoplan_core.core.module_guards import (
|
|
drop_table_retirement_provider,
|
|
persistent_table_uninstall_guard,
|
|
)
|
|
from govoplan_core.core.modules import (
|
|
CapabilityDocumentation,
|
|
DocumentationCondition,
|
|
DocumentationLink,
|
|
DocumentationTopic,
|
|
FrontendModule,
|
|
FrontendRoute,
|
|
MigrationSpec,
|
|
ModuleContext,
|
|
ModuleInterfaceProvider,
|
|
ModuleManifest,
|
|
PermissionDefinition,
|
|
RoleTemplate,
|
|
)
|
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
|
from govoplan_core.core.search import (
|
|
CAPABILITY_SEARCH_INDEX_WRITER,
|
|
SearchProviderRegistration,
|
|
)
|
|
from govoplan_core.core.views import ViewSurface
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_search.backend.db import models as search_models
|
|
from govoplan_search.backend.dsar_provider import (
|
|
SEARCH_DSAR_CAPABILITY,
|
|
SearchDsarProvider,
|
|
)
|
|
|
|
|
|
MODULE_ID = "search"
|
|
MODULE_NAME = "Search"
|
|
MODULE_VERSION = "0.1.20"
|
|
READ_SCOPE = "search:result:read"
|
|
INDEX_SCOPE = "search:index:write"
|
|
ADMIN_SCOPE = "search:index:admin"
|
|
|
|
|
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
|
module_id, resource, action = scope.split(":", 2)
|
|
return PermissionDefinition(
|
|
scope=scope,
|
|
label=label,
|
|
description=description,
|
|
category="Search",
|
|
level="tenant",
|
|
module_id=module_id,
|
|
resource=resource,
|
|
action=action,
|
|
)
|
|
|
|
|
|
PERMISSIONS = (
|
|
_permission(
|
|
READ_SCOPE,
|
|
"Search available content",
|
|
"Search content the current principal is authorized to read.",
|
|
),
|
|
_permission(
|
|
INDEX_SCOPE,
|
|
"Write search index entries",
|
|
"Publish and remove module-owned entries in the search index.",
|
|
),
|
|
_permission(
|
|
ADMIN_SCOPE,
|
|
"Administer search index",
|
|
"Inspect providers and rebuild tenant search indexes.",
|
|
),
|
|
)
|
|
|
|
ROLE_TEMPLATES = (
|
|
RoleTemplate(
|
|
slug="search_user",
|
|
name="Search user",
|
|
description="Search content available to the current account.",
|
|
permissions=(READ_SCOPE,),
|
|
default_authenticated=True,
|
|
),
|
|
RoleTemplate(
|
|
slug="search_manager",
|
|
name="Search manager",
|
|
description="Search content and administer indexing.",
|
|
permissions=(READ_SCOPE, INDEX_SCOPE, ADMIN_SCOPE),
|
|
),
|
|
)
|
|
|
|
|
|
def _service(context: ModuleContext):
|
|
from govoplan_search.backend.service import SearchIndexService
|
|
|
|
return SearchIndexService(context.registry)
|
|
|
|
|
|
def _router(_context: ModuleContext):
|
|
from govoplan_search.backend.router import router
|
|
|
|
return router
|
|
|
|
|
|
def _dsar_provider(context: ModuleContext) -> SearchDsarProvider:
|
|
del context
|
|
return SearchDsarProvider()
|
|
|
|
|
|
manifest = ModuleManifest(
|
|
id=MODULE_ID,
|
|
name=MODULE_NAME,
|
|
version=MODULE_VERSION,
|
|
optional_dependencies=(
|
|
"access",
|
|
"views",
|
|
"connectors",
|
|
"wiki",
|
|
"projects",
|
|
"tickets",
|
|
"cases",
|
|
),
|
|
required_capabilities=(
|
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
|
),
|
|
provides_interfaces=(
|
|
ModuleInterfaceProvider(name="search.provider", version="1.0.0"),
|
|
ModuleInterfaceProvider(name="search.index_writer", version="1.1.0"),
|
|
ModuleInterfaceProvider(name="search.source", version="1.0.0"),
|
|
ModuleInterfaceProvider(name=SEARCH_DSAR_CAPABILITY, version="0.1.0"),
|
|
),
|
|
permissions=PERMISSIONS,
|
|
role_templates=ROLE_TEMPLATES,
|
|
route_factory=_router,
|
|
frontend=FrontendModule(
|
|
module_id=MODULE_ID,
|
|
package_name="@govoplan/search-webui",
|
|
routes=(
|
|
FrontendRoute(
|
|
path="/search",
|
|
component="SearchPage",
|
|
required_any=(READ_SCOPE,),
|
|
order=12,
|
|
),
|
|
),
|
|
view_surfaces=(
|
|
ViewSurface(
|
|
id="search.global",
|
|
module_id=MODULE_ID,
|
|
kind="selector",
|
|
label="Global search",
|
|
description="Search entry in the title bar.",
|
|
order=10,
|
|
),
|
|
ViewSurface(
|
|
id="search.results",
|
|
module_id=MODULE_ID,
|
|
kind="route",
|
|
label="Search results",
|
|
order=20,
|
|
),
|
|
ViewSurface(
|
|
id="search.admin.index",
|
|
module_id=MODULE_ID,
|
|
kind="section",
|
|
label="Search index administration",
|
|
order=30,
|
|
),
|
|
),
|
|
),
|
|
migration_spec=MigrationSpec(
|
|
module_id=MODULE_ID,
|
|
metadata=Base.metadata,
|
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
|
retirement_supported=True,
|
|
retirement_provider=drop_table_retirement_provider(
|
|
search_models.SearchIndexChangeQueue,
|
|
search_models.SearchIndexAclToken,
|
|
search_models.SearchIndexDocument,
|
|
search_models.SearchIndexState,
|
|
label="Search",
|
|
),
|
|
retirement_notes=(
|
|
"Destructive retirement removes the derived search index. "
|
|
"Source module data remains authoritative."
|
|
),
|
|
),
|
|
uninstall_guard_providers=(
|
|
persistent_table_uninstall_guard(
|
|
search_models.SearchIndexChangeQueue,
|
|
search_models.SearchIndexDocument,
|
|
search_models.SearchIndexState,
|
|
label="Search index",
|
|
),
|
|
),
|
|
capability_factories={
|
|
CAPABILITY_SEARCH_INDEX_WRITER: _service,
|
|
SEARCH_DSAR_CAPABILITY: _dsar_provider,
|
|
},
|
|
capability_documentation={
|
|
SEARCH_DSAR_CAPABILITY: CapabilityDocumentation(
|
|
label="Search data-subject request provider",
|
|
summary="Finds and purges derived index copies while preserving source authority.",
|
|
contract_version="0.1.0",
|
|
),
|
|
},
|
|
search_providers=(
|
|
SearchProviderRegistration(
|
|
id="search.index",
|
|
factory=_service,
|
|
order=10,
|
|
),
|
|
),
|
|
documentation=(
|
|
DocumentationTopic(
|
|
id="search.data-subject-requests",
|
|
title="Derived Search data-subject requests",
|
|
summary="Remove derived index copies without treating Search as the authoritative data owner.",
|
|
body=(
|
|
"Search correlates explicit index/change identifiers and provider-owned source references inside the exact tenant. Account, identity, and membership selectors expose only minimized ACL projections and never imply ownership of the indexed source object. Indexed title, summary, body, search text, URL, keywords, metadata, external-reference payloads, ACL token values, hashes, cursors, queued documents, and errors are excluded. "
|
|
"Derived document and queued-change rows may be deleted idempotently. ACL-only matches require source-authority review. Source correction or erasure must happen in the owner module before Search is rebuilt; otherwise the source can legitimately republish the derived entry."
|
|
),
|
|
layer="configured",
|
|
documentation_types=("admin", "user"),
|
|
audience=("user", "operator", "module_admin", "auditor"),
|
|
related_modules=("core", "access"),
|
|
metadata={
|
|
"kind": "reference",
|
|
"help_contexts": [
|
|
"search.data-subject-requests",
|
|
"search.admin.index",
|
|
],
|
|
"consequence_classes": {
|
|
"purge_derived_index": (
|
|
"Deletes matching derived documents and queued changes idempotently."
|
|
),
|
|
"preserve_authoritative_source": (
|
|
"Does not correct or erase the authoritative object owned by its source module."
|
|
),
|
|
"review_acl_only_matches": (
|
|
"Requires source-authority review when only an ACL projection identifies the subject."
|
|
),
|
|
},
|
|
},
|
|
translations={
|
|
"de": {
|
|
"title": "Datenschutzanfragen zum abgeleiteten Suchindex",
|
|
"summary": (
|
|
"Abgeleitete Indexkopien entfernen, ohne Search als führende Quelle "
|
|
"der indexierten Objekte zu behandeln."
|
|
),
|
|
"body": (
|
|
"Search gleicht innerhalb des exakten Mandanten nur ausdrückliche Index- "
|
|
"oder Änderungskennungen und quellanbietergeführte Referenzen ab. Selektoren "
|
|
"für Konten, Identitäten und Mitgliedschaften legen ausschließlich minimierte "
|
|
"Berechtigungsprojektionen offen und begründen niemals die Eigentümerschaft am "
|
|
"indexierten Quellobjekt. Indexierter Titel, Zusammenfassung, Inhalt, Suchtext, "
|
|
"URL, Schlüsselwörter, Metadaten, externe Referenzinhalte, Berechtigungswerte, "
|
|
"Prüfsummen, Cursor, eingereihte Dokumente und Fehler bleiben ausgeschlossen. "
|
|
"Abgeleitete Dokumente und eingereihte Änderungen können idempotent gelöscht "
|
|
"werden. Treffer allein über Berechtigungsprojektionen erfordern eine Prüfung "
|
|
"durch die führende Quelle. Berichtigung oder Löschung muss im Eigentümermodul "
|
|
"erfolgen, bevor Search neu aufgebaut wird; andernfalls darf die Quelle den "
|
|
"abgeleiteten Eintrag erneut veröffentlichen."
|
|
),
|
|
}
|
|
},
|
|
structured_translation_version="1",
|
|
structured_translations={
|
|
"de": {
|
|
"consequence_classes": {
|
|
"purge_derived_index": (
|
|
"Löscht passende abgeleitete Dokumente und eingereihte Änderungen idempotent."
|
|
),
|
|
"preserve_authoritative_source": (
|
|
"Berichtigt oder löscht nicht das führende Objekt des jeweiligen Quellmoduls."
|
|
),
|
|
"review_acl_only_matches": (
|
|
"Erfordert eine Prüfung durch die führende Quelle, wenn die betroffene Person nur über eine Berechtigungsprojektion erkannt wird."
|
|
),
|
|
}
|
|
}
|
|
},
|
|
links=(
|
|
DocumentationLink(
|
|
label="Search index lifecycle",
|
|
href="govoplan-search/README.md",
|
|
kind="repository",
|
|
),
|
|
),
|
|
order=11,
|
|
),
|
|
DocumentationTopic(
|
|
id="search.global-and-contextual",
|
|
title="Global and contextual search",
|
|
summary=(
|
|
"Search authorized native and connected objects from one "
|
|
"permission-aware interface."
|
|
),
|
|
body=(
|
|
"Documentation books sit immediately beside the visible heading or contextual label for "
|
|
"Search and Search index, not among operational action buttons. Field help remains beside its "
|
|
"label. "
|
|
"Search works with the built-in database index and can aggregate "
|
|
"optional providers. Source modules announce searchable types, "
|
|
"context scopes, and ACL-aware index entries. External engines "
|
|
"remain optional adapters. The title-bar Search command, F3, "
|
|
"or Ctrl/Cmd+K opens the "
|
|
"keyboard-navigable search overlay; filters never broaden the "
|
|
"current principal's source permissions. The overlay and full "
|
|
"Search page use the same Modules and Result types dropdowns. "
|
|
"Select all leaves a group unrestricted; deselecting every "
|
|
"option intentionally returns no results. Clear filters keeps "
|
|
"the query and current context. Provider failures are "
|
|
"shown as partial diagnostics without discarding safe results."
|
|
" Search administrators can inspect native source coverage, "
|
|
"process queued changes, reconcile enabled modules, and run "
|
|
"bounded source rebuilds from Administration. Quarantined "
|
|
"changes remain visible until repaired and reconciled."
|
|
),
|
|
layer="available",
|
|
documentation_types=("admin", "user"),
|
|
audience=("administrator", "user"),
|
|
related_modules=("connectors", "views"),
|
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
|
metadata={
|
|
"kind": "workflow",
|
|
"help_contexts": [
|
|
"search.global",
|
|
"search.results",
|
|
"search.filters",
|
|
"search.admin.index",
|
|
],
|
|
"purpose": (
|
|
"Find currently authorized native and connected objects without broadening source permissions."
|
|
),
|
|
"prerequisites": [
|
|
"The actor has Search read access and the source module authorizes each returned object now.",
|
|
"At least one enabled source provider has published or can supply a searchable projection.",
|
|
],
|
|
"steps": [
|
|
"Open Search from the title bar, F3, or Ctrl/Cmd+K and enter a precise term.",
|
|
"Use the shared Modules and Result types dropdowns on the overlay or full Search page. Multiple values in one group are alternatives; both groups must match.",
|
|
"Select all removes a group's restriction. Deselect all intentionally shows no results; select an option or use Clear filters to search again.",
|
|
"In the overlay, a module context bounds the available result types. An incompatible type selection shows no results rather than expanding to other types. Clear filters keeps the selected context and query.",
|
|
"Use Tab to reach filter controls and Space to toggle an option. Escape closes the open dropdown first and returns focus to its button; another Escape closes the Search overlay.",
|
|
"Review partial-provider and quarantine diagnostics before relying on completeness.",
|
|
"Open an authorized result; Search rechecks the provider-owned object permission before disclosure.",
|
|
"Administrators may process queued changes, reconcile providers, or start a bounded rebuild when diagnostics require it.",
|
|
],
|
|
"limitations": [
|
|
"Search is a derived discovery layer and is never authoritative for source content or access decisions.",
|
|
"A provider failure can make results incomplete but never permits unsafe results to bypass ACL filtering.",
|
|
"Filter choices describe the available source catalogue and observed results; they are not permission-filtered result counts or a promise that a source has matching objects.",
|
|
"The full Search page preserves query and filter selections in its URL, including explicit no-selection states. The title-bar overlay keeps its selections locally while mounted.",
|
|
],
|
|
"operational_consequences": {
|
|
"process_queue": "Applies pending derived index changes under current tenant and provider boundaries.",
|
|
"reconcile": "Compares enabled source coverage and retains unresolved changes in quarantine.",
|
|
"rebuild": "Recreates bounded derived projections while source data remains authoritative.",
|
|
"change_filters": "Discards obsolete result pages when query, filters, context, account/tenant authority, or API identity changes, even if the API token is unchanged. Selecting no modules or no result types performs no provider search and does not change source data or permissions.",
|
|
},
|
|
"verification": [
|
|
"Every displayed result names its source and remains openable by the current principal.",
|
|
"Partial-provider failures and quarantined changes remain visible as diagnostics.",
|
|
"Administrative rebuild status can be reconciled against the enabled provider inventory.",
|
|
"Select all, a subset, and no options produce distinct states; a full-page URL reload preserves the chosen filters without discarding the query or context.",
|
|
"Changing filters during a pending request never appends the previous query's next page to the new result set.",
|
|
],
|
|
},
|
|
translations={
|
|
"de": {
|
|
"title": "Globale und kontextbezogene Suche",
|
|
"summary": (
|
|
"Berechtigte native und angebundene Objekte über eine gemeinsame, "
|
|
"berechtigungsbewusste Oberfläche finden."
|
|
),
|
|
"body": (
|
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
|
"Kontextbezeichnung für Suche und Suchindex, nicht zwischen ausführbaren "
|
|
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
|
|
"Search verwendet den eingebauten Datenbankindex und kann optionale Anbieter "
|
|
"zusammenführen. Quellmodule melden durchsuchbare Typen, Kontextbereiche und "
|
|
"berechtigungsgeprüfte Indexeinträge. Externe Suchmaschinen bleiben optionale "
|
|
"Adapter. Der Suchbefehl in der Titelleiste, F3 oder Strg/Cmd+K öffnet die per "
|
|
"Tastatur bedienbare Suchüberlagerung; Filter erweitern niemals die "
|
|
"Quellberechtigungen der aktuellen Person. Überlagerung und vollständige "
|
|
"Suchseite verwenden dieselben Auswahllisten für Module und Ergebnistypen. "
|
|
"Alle auswählen lässt eine Gruppe uneingeschränkt; alle Optionen abzuwählen "
|
|
"liefert bewusst keine Ergebnisse. Filter zurücksetzen behält Suchbegriff "
|
|
"und aktuellen Kontext bei. Anbieterausfälle werden als partielle "
|
|
"Diagnosen angezeigt, ohne sichere Ergebnisse zu verwerfen. Administratoren "
|
|
"können die Abdeckung nativer Quellen prüfen, eingereihte Änderungen verarbeiten, "
|
|
"aktivierte Module abgleichen und begrenzte Neuaufbauten aus der Administration "
|
|
"starten. Änderungen in Quarantäne bleiben sichtbar, bis sie repariert und "
|
|
"abgeglichen wurden."
|
|
),
|
|
}
|
|
},
|
|
structured_translation_version="1",
|
|
structured_translations={
|
|
"de": {
|
|
"purpose": (
|
|
"Aktuell berechtigte native und angebundene Objekte finden, ohne Quellberechtigungen zu erweitern."
|
|
),
|
|
"prerequisites": [
|
|
"Die handelnde Person darf Search lesen und das Quellmodul autorisiert jedes zurückgegebene Objekt weiterhin.",
|
|
"Mindestens ein aktivierter Quellanbieter hat eine durchsuchbare Projektion veröffentlicht oder kann sie bereitstellen.",
|
|
],
|
|
"steps": [
|
|
"Search über die Titelleiste, F3 oder Strg/Cmd+K öffnen und einen präzisen Suchbegriff eingeben.",
|
|
"In der Überlagerung oder auf der vollständigen Suchseite die gemeinsamen Auswahllisten Module und Ergebnistypen verwenden. Mehrere Werte innerhalb einer Gruppe gelten als Alternativen; beide Gruppen müssen passen.",
|
|
"Alle auswählen hebt die Einschränkung einer Gruppe auf. Alle abwählen zeigt bewusst keine Ergebnisse; eine Option auswählen oder Filter zurücksetzen verwenden, um erneut zu suchen.",
|
|
"In der Überlagerung begrenzt ein Modulkontext die verfügbaren Ergebnistypen. Eine unvereinbare Typauswahl zeigt keine Ergebnisse, statt die Suche auf andere Typen auszuweiten. Filter zurücksetzen behält gewählten Kontext und Suchbegriff bei.",
|
|
"Mit Tab die Filtersteuerung erreichen und mit der Leertaste eine Option umschalten. Escape schließt zuerst die geöffnete Auswahlliste und setzt den Fokus auf ihre Schaltfläche zurück; ein weiteres Escape schließt die Suchüberlagerung.",
|
|
"Diagnosen zu partiellen Anbieterausfällen und Quarantäne prüfen, bevor Vollständigkeit angenommen wird.",
|
|
"Ein berechtigtes Ergebnis öffnen; Search prüft vor der Offenlegung erneut die Berechtigung am quellengeführten Objekt.",
|
|
"Administratoren können bei entsprechenden Diagnosen eingereihte Änderungen verarbeiten, Anbieter abgleichen oder einen begrenzten Neuaufbau starten.",
|
|
],
|
|
"limitations": [
|
|
"Search ist eine abgeleitete Auffindbarkeitsschicht und niemals führend für Quellinhalte oder Zugriffsentscheidungen.",
|
|
"Ein Anbieterausfall kann Ergebnisse unvollständig machen, erlaubt aber niemals das Umgehen der Berechtigungsfilterung.",
|
|
"Filteroptionen beschreiben den verfügbaren Quellenkatalog und beobachtete Ergebnisse; sie sind weder berechtigungsgefilterte Trefferzahlen noch eine Zusage, dass eine Quelle passende Objekte enthält.",
|
|
"Die vollständige Suchseite erhält Suchbegriff und Filterauswahl in ihrer URL, auch ausdrücklich leere Auswahlen. Die Überlagerung der Titelleiste behält ihre Auswahl lokal, solange sie eingebunden bleibt.",
|
|
],
|
|
"operational_consequences": {
|
|
"process_queue": "Verarbeitet ausstehende abgeleitete Indexänderungen innerhalb der aktuellen Mandanten- und Anbietergrenzen.",
|
|
"reconcile": "Vergleicht die Abdeckung aktivierter Quellen und hält ungeklärte Änderungen in Quarantäne.",
|
|
"rebuild": "Erstellt begrenzte abgeleitete Projektionen neu, während die Quelldaten führend bleiben.",
|
|
"change_filters": "Verwirft veraltete Ergebnisseiten, wenn sich Suchbegriff, Filter, Kontext, Konto, Mandant, Berechtigungen oder API-Identität ändern, auch bei unverändertem API-Token. Eine leere Modul- oder Ergebnistypauswahl führt keine Anbietersuche aus und ändert weder Quelldaten noch Berechtigungen.",
|
|
},
|
|
"verification": [
|
|
"Jedes angezeigte Ergebnis nennt seine Quelle und kann von der aktuellen Person weiterhin geöffnet werden.",
|
|
"Partielle Anbieterausfälle und Änderungen in Quarantäne bleiben als Diagnosen sichtbar.",
|
|
"Der administrative Neuaufbaustatus lässt sich mit dem Inventar aktivierter Anbieter abgleichen.",
|
|
"Alle auswählen, eine Teilmenge und keine Optionen ergeben unterschiedliche Zustände; das Neuladen der vollständigen Suchseiten-URL erhält die gewählten Filter, ohne Suchbegriff oder Kontext zu verwerfen.",
|
|
"Ein Filterwechsel während einer laufenden Anfrage hängt niemals die nächste Seite des vorherigen Suchbegriffs an die neue Ergebnisliste an.",
|
|
],
|
|
}
|
|
},
|
|
links=(
|
|
DocumentationLink(
|
|
label="Search interface pattern audit",
|
|
href="govoplan-search/docs/INTERFACE_PATTERN_MIGRATION.md",
|
|
kind="repository",
|
|
),
|
|
),
|
|
order=12,
|
|
),
|
|
),
|
|
architecture=declared_module_architecture(
|
|
layer="governance_accountability",
|
|
kind="runtime",
|
|
maturity="vertical_slice",
|
|
documentation_ref="README.md",
|
|
test_ref="tests/test_postgres_search.py",
|
|
known_limits=(
|
|
"The built-in PostgreSQL index is implemented; optional OpenSearch target evidence is not.",
|
|
),
|
|
supported_authority_modes=("external_mirror",),
|
|
owned_concepts=(
|
|
"derived search index",
|
|
"search ACL projection",
|
|
"index change queue",
|
|
),
|
|
non_owned_concepts=(
|
|
"source object",
|
|
"source authorization",
|
|
"external search engine",
|
|
),
|
|
recovery_docs=("README.md",),
|
|
security_docs=("README.md",),
|
|
operations_docs=("README.md",),
|
|
),
|
|
)
|
|
|
|
|
|
def get_manifest() -> ModuleManifest:
|
|
return manifest
|
|
|
|
|
|
__all__ = [
|
|
"ADMIN_SCOPE",
|
|
"INDEX_SCOPE",
|
|
"MODULE_ID",
|
|
"MODULE_VERSION",
|
|
"READ_SCOPE",
|
|
"get_manifest",
|
|
"manifest",
|
|
]
|