sshuair09 2 hafta önce
ebeveyn
işleme
0916905e31
100 değiştirilmiş dosya ile 10424 ekleme ve 56 silme
  1. 178 0
      .claude/skills/flowsint-enricher-builder/SKILL.md
  2. 66 0
      .dockerignore
  3. 12 0
      .env.example
  4. 175 0
      .github/workflows/images.yml
  5. 181 0
      .gitignore
  6. 4 0
      .husky/commit-msg
  7. 1 0
      .python-version
  8. 67 0
      .versionrc.json
  9. 6 0
      CHANGELOG.md
  10. 29 0
      DISCLAIMER.md
  11. 54 0
      ETHICS.md
  12. 201 0
      LICENSE
  13. 234 0
      Makefile
  14. 30 0
      NOTICE
  15. 232 56
      README.md
  16. 24 0
      commitlint.config.js
  17. 176 0
      docker-compose.dev.yml
  18. 172 0
      docker-compose.prod.yml
  19. 84 0
      docker-compose.yml
  20. 18 0
      docs/developers/getting-started.mdx
  21. 244 0
      docs/developers/graph-format.mdx
  22. 715 0
      docs/developers/managing-enrichers.mdx
  23. 512 0
      docs/developers/managing-tools.mdx
  24. 1253 0
      docs/developers/managing-types.mdx
  25. 53 0
      docs/getting-started/enrichers.mdx
  26. 159 0
      docs/getting-started/flows.mdx
  27. 42 0
      docs/getting-started/quickstart.mdx
  28. 58 0
      docs/getting-started/vault.mdx
  29. 59 0
      docs/overview.mdx
  30. 163 0
      docs/sources/available-enrichers.mdx
  31. 73 0
      docs/syllabus.mdx
  32. 176 0
      flowsint-api/.gitignore
  33. 123 0
      flowsint-api/Dockerfile
  34. 18 0
      flowsint-api/README.md
  35. 38 0
      flowsint-api/alembic.ini
  36. 1 0
      flowsint-api/alembic/README
  37. 57 0
      flowsint-api/alembic/env.py
  38. 28 0
      flowsint-api/alembic/script.py.mako
  39. 32 0
      flowsint-api/alembic/versions/0160b0f70a02_add_context_to_chat_message.py
  40. 32 0
      flowsint-api/alembic/versions/0ab8ee0a782c_add_cascade_delete_to_messages.py
  41. 55 0
      flowsint-api/alembic/versions/1098b7a5eabc_change_keys_structure_with_iv_salt_.py
  42. 32 0
      flowsint-api/alembic/versions/1d0f26dbbef5_add_passive_delete_v2.py
  43. 40 0
      flowsint-api/alembic/versions/2da47dbd4a52_add_cascade_delete_to_scans_and_logs.py
  44. 38 0
      flowsint-api/alembic/versions/40ece72583b7_add_email_and_hashed_password_to_profile.py
  45. 36 0
      flowsint-api/alembic/versions/661ff8ef4425_rename_transforms_to_flows.py
  46. 35 0
      flowsint-api/alembic/versions/6be831edfda7_add_investigation_roles_permissions.py
  47. 90 0
      flowsint-api/alembic/versions/6dfa83113ad7_change_content_colum_of_log_to_json.py
  48. 63 0
      flowsint-api/alembic/versions/6e49acfb3816_add_investigation_roles_permissions.py
  49. 103 0
      flowsint-api/alembic/versions/71a3e5b4db2a_update_scan_status_enum.py
  50. 32 0
      flowsint-api/alembic/versions/76f5436251e3_add_relationship_between_investigations_.py
  51. 50 0
      flowsint-api/alembic/versions/8173aba964e7_add_custom_types_table.py
  52. 59 0
      flowsint-api/alembic/versions/8ac522441108_add_chat_and_chat_message.py
  53. 32 0
      flowsint-api/alembic/versions/8d0e12b68d1e_fix_backpopulate_issue.py
  54. 151 0
      flowsint-api/alembic/versions/965b56353b4c_initial_migration.py
  55. 58 0
      flowsint-api/alembic/versions/9a3b9a199aa8_drop_third_party_keys_create_keys_table.py
  56. 107 0
      flowsint-api/alembic/versions/a1b2c3d4e5f6_make_column_types_portable.py
  57. 59 0
      flowsint-api/alembic/versions/a1f2b3c4d5e6_backfill_owner_roles.py
  58. 32 0
      flowsint-api/alembic/versions/afdaf9aa539c_add_passive_delete.py
  59. 28 0
      flowsint-api/alembic/versions/b2c3d4e5f6a7_add_description_to_enricher_templates.py
  60. 36 0
      flowsint-api/alembic/versions/ba3d00e11612_add_cascade_delete_to_scans_and_logs.py
  61. 42 0
      flowsint-api/alembic/versions/bac5764d4496_add_icon_and_color_to_custom_types.py
  62. 33 0
      flowsint-api/alembic/versions/c82bf6af92e5_add_investigation_roles_permissions.py
  63. 80 0
      flowsint-api/alembic/versions/c9d8e7f6a5b4_add_enricher_templates_table.py
  64. 32 0
      flowsint-api/alembic/versions/d0a8e5b5a7b9_add_relationship_between_investigations_.py
  65. 32 0
      flowsint-api/alembic/versions/d39941278a91_init.py
  66. 45 0
      flowsint-api/alembic/versions/e403a4152f6b_add_third_party_keys_table.py
  67. 28 0
      flowsint-api/alembic/versions/f5fae279ec04_merge_portable_column_types_and_.py
  68. 48 0
      flowsint-api/alembic/versions/fa0ab51b2f64_add_analysis_model_and_investigation_.py
  69. 34 0
      flowsint-api/alembic/versions/faceebd6a580_remove_scan_id_of_logs.py
  70. 0 0
      flowsint-api/app/__init__.py
  71. 0 0
      flowsint-api/app/api/__init__.py
  72. 70 0
      flowsint-api/app/api/deps.py
  73. 0 0
      flowsint-api/app/api/routes/__init__.py
  74. 113 0
      flowsint-api/app/api/routes/analysis.py
  75. 81 0
      flowsint-api/app/api/routes/auth.py
  76. 114 0
      flowsint-api/app/api/routes/chat.py
  77. 166 0
      flowsint-api/app/api/routes/custom_types.py
  78. 206 0
      flowsint-api/app/api/routes/enricher_templates.py
  79. 96 0
      flowsint-api/app/api/routes/enrichers.py
  80. 208 0
      flowsint-api/app/api/routes/events.py
  81. 523 0
      flowsint-api/app/api/routes/flows.py
  82. 267 0
      flowsint-api/app/api/routes/investigations.py
  83. 105 0
      flowsint-api/app/api/routes/keys.py
  84. 59 0
      flowsint-api/app/api/routes/scan.py
  85. 532 0
      flowsint-api/app/api/routes/sketches.py
  86. 38 0
      flowsint-api/app/api/routes/types.py
  87. 0 0
      flowsint-api/app/api/schemas/__init__.py
  88. 32 0
      flowsint-api/app/api/schemas/analysis.py
  89. 6 0
      flowsint-api/app/api/schemas/base.py
  90. 34 0
      flowsint-api/app/api/schemas/chat.py
  91. 88 0
      flowsint-api/app/api/schemas/custom_type.py
  92. 27 0
      flowsint-api/app/api/schemas/enricher.py
  93. 190 0
      flowsint-api/app/api/schemas/enricher_template.py
  94. 16 0
      flowsint-api/app/api/schemas/feedback.py
  95. 29 0
      flowsint-api/app/api/schemas/flow.py
  96. 54 0
      flowsint-api/app/api/schemas/investigation.py
  97. 18 0
      flowsint-api/app/api/schemas/investigation_profiles.py
  98. 21 0
      flowsint-api/app/api/schemas/key.py
  99. 24 0
      flowsint-api/app/api/schemas/profile.py
  100. 17 0
      flowsint-api/app/api/schemas/scan.py

+ 178 - 0
.claude/skills/flowsint-enricher-builder/SKILL.md

@@ -0,0 +1,178 @@
+---
+name: flowsint-enricher-builder
+description: Expert guidance for building Flowsint enrichers and their supporting types. Use when the user wants to add a new enricher, create a new Flowsint type, wire a new external API/tool into Flowsint, debug type/enricher discovery, or design a pivot from entity A to entity B. Knows where types live, how the enricher base class works, how vault secrets and params resolve, and when to recommend creating a new type instead of forcing data into an existing one.
+---
+
+# Flowsint Enricher Builder
+
+You build enrichers and types for Flowsint. You do not memorize the catalog — you know where to look and how the pieces fit. Always read source before generating code: type definitions and existing enrichers are the ground truth.
+
+## Authoritative source paths
+
+Read these first. Never assume signatures or fields — open the file.
+
+| What | Path |
+|---|---|
+| Type definitions | `flowsint-types/src/flowsint_types/<name>.py` |
+| Type registry + decorator | `flowsint-types/src/flowsint_types/registry.py` |
+| Type package exports | `flowsint-types/src/flowsint_types/__init__.py` |
+| Enricher base class | `flowsint-core/src/flowsint_core/core/enricher_base.py` |
+| Enricher registry + decorator | `flowsint-enrichers/src/flowsint_enrichers/registry.py` |
+| Existing enrichers (templates) | `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<output>.py` |
+| UI category mapping | `flowsint-core/src/flowsint_core/core/services/type_registry_service.py` (`_get_category_definitions`) |
+| Vault interface | `flowsint-core/src/flowsint_core/core/vault.py` |
+| Logger interface | `flowsint-core/src/flowsint_core/core/logger.py` |
+| Tools (external CLI/API wrappers) | `tools/` (top-level), e.g. `tools.network.subfinder.SubfinderTool` |
+| Doc — types tutorial | `docs/developers/managing-types.mdx` |
+| Doc — enrichers tutorial | `docs/developers/managing-enrichers.mdx` |
+| Doc — enricher catalog | `docs/sources/available-enrichers.mdx` |
+
+## The first question: new type or reuse?
+
+When the user describes a enricher, decide before writing code:
+
+1. **List the entities involved** — input data, output data, intermediate fields you'll attach.
+2. **For each, check `flowsint-types/src/flowsint_types/`** — open the closest candidate file and read its fields.
+3. **Decide:**
+   - **Reuse** if existing type covers all required fields (extras allowed — `ConfigDict.extra = "allow"`).
+   - **Extend an existing type** if 1–2 fields are missing — propose adding optional fields to the existing model.
+   - **Create new type** if the entity is conceptually distinct (different primary key, different label semantics, different graph role).
+4. **Never cram data into a wrong type.** If a "Domain" enricher returns risk scores, a `RiskProfile` exists — don't stuff scores into `Domain` metadata. If nothing fits, propose a new type and tell the user why.
+
+Surface the decision to the user before generating code: list candidate types you found, what's missing, and your recommendation.
+
+## Anatomy of an enricher
+
+Minimum surface (read `enricher_base.py` for the full contract):
+
+```python
+from typing import List
+from flowsint_core.core.enricher_base import Enricher
+from flowsint_core.core.logger import Logger
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_types import Domain, Ip  # or whatever types
+
+@flowsint_enricher
+class MyEnricher(Enricher):
+    """[Source name] One-line purpose."""
+
+    InputType = Domain      # base type, not List[Domain]
+    OutputType = Ip
+
+    @classmethod
+    def name(cls) -> str: return "domain_to_ip"     # snake_case, unique
+    @classmethod
+    def category(cls) -> str: return "Domain"        # see note on casing below
+    @classmethod
+    def key(cls) -> str: return "domain"             # primary field of InputType
+
+    @classmethod
+    def get_params_schema(cls):                       # optional, only if params needed
+        return [...]
+
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        ...
+
+    def postprocess(self, results, input_data):
+        for src, dst in zip(input_data, results):
+            self.create_node(src)
+            self.create_node(dst)
+            self.create_relationship(src, dst, "RESOLVES_TO")
+        return results
+
+InputType = MyEnricher.InputType
+OutputType = MyEnricher.OutputType
+```
+
+**File location:** `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<target>.py`. The directory matches the input type's lowercase name. If no directory exists for your input type, create it (no `__init__.py` needed — auto-discovery walks the tree).
+
+**Registration:** the `@flowsint_enricher` decorator does it. Do not edit any `registry.py`. API restart picks up new files via `load_all_enrichers()`.
+
+## Params and secrets
+
+Defined via `get_params_schema()` classmethod. Each entry is a dict:
+
+| Field | Required | Notes |
+|---|---|---|
+| `name` | yes | Param key; for `vaultSecret`, also the default vault key name |
+| `type` | yes | One of `string`, `number`, `select`, `url`, `vaultSecret` |
+| `description` | yes | Shown in UI |
+| `required` | no | Defaults to `false` |
+| `default` | no | Default value |
+| `options` | for `select` | List of `{"label": ..., "value": ...}` |
+
+Read params inside `scan()`:
+
+```python
+mode = self.params.get("mode", "passive")
+api_key = self.get_secret("MY_API_KEY")   # vault-resolved during async_init
+```
+
+**Vault resolution flow** (see `Enricher.resolve_params` in `enricher_base.py`):
+1. If user passed a vault ID in params → vault looked up by that ID.
+2. Else → vault looked up by the param name (e.g. `MY_API_KEY`).
+3. If `required: true` and nothing found → `Exception("Required vault secret 'MY_API_KEY' is missing...")`.
+
+**Never hardcode keys.** Always declare a `vaultSecret` param. Document the expected vault key name in the docstring.
+
+## Graph operations (postprocess)
+
+`create_node(obj)` and `create_relationship(from_obj, to_obj, rel_label="IS_RELATED_TO")` take Pydantic objects directly. Don't manually construct node dicts — pass the typed instance.
+
+Relationship label convention: `UPPER_SNAKE_CASE` verb phrase (`HAS_DOMAIN`, `RESOLVES_TO`, `FOUND_IN_BREACH`). Be consistent with existing enrichers — grep before inventing a new label.
+
+`self.log_graph_message("...")` for graph-related progress logs. `Logger.info / error / warn(self.sketch_id, {"message": "..."})` for general logs.
+
+## Creating a new type — checklist
+
+When you decide a new type is warranted:
+
+1. **File**: `flowsint-types/src/flowsint_types/<snake_case>.py`.
+2. **Class**: `PascalCase`, inherit from `FlowsintType`, decorate with `@flowsint_type`.
+3. **Exactly one primary field**: `Field(..., json_schema_extra={"primary": True})`. Must uniquely identify the entity (used as Neo4j MERGE key).
+4. **`compute_label`**: `@model_validator(mode='after')`, sets `self.nodeLabel`, returns `self`. Handle `None` for optional fields.
+5. **Export in `__init__.py`**: add import + entry in `__all__`.
+6. **Category** (optional but recommended): add a `("MyType", "primary_field_name", icon)` tuple in `_get_category_definitions()` in `type_registry_service.py`. Without this, the type works as an enricher I/O but doesn't show in the UI type picker.
+7. **Reinstall**: `cd flowsint-types && poetry install` (or `make prod` from repo root).
+8. **Test**: write a `tests/test_<name>.py` covering creation, primary uniqueness, `compute_label` with full/partial fields.
+
+Full template + patterns: `docs/developers/managing-types.mdx`.
+
+## Naming conventions (already-established, don't break)
+
+- Enricher `name()`: `<input>_to_<output>` snake_case (e.g. `domain_to_ip`, `email_to_breaches`).
+- Enricher file: `to_<target>.py` under `<input_type>/` directory.
+- Class name: descriptive PascalCase (e.g. `DomainToIpEnricher`, `WhoisEnricher`).
+- Type class: PascalCase. Type file: snake_case.
+- Relationship label: `UPPER_SNAKE_CASE` verb.
+- Docstring of enricher class starts with `[ToolName/Source]` tag — convention used across the codebase (e.g. `"""[DeHashed] Get breach intelligence ..."""`).
+
+**Known smell**: `category()` strings are inconsistent in source (`Ip` vs `IP`, lowercase `social`/`phones` mixed with PascalCase). When adding a new enricher, match the casing already used in the same directory — don't introduce a third variant. If the user asks for a cleanup pass, flag it as a separate task.
+
+## Workflow to follow per request
+
+1. **Read the user's goal**: input entity, desired output, data source/tool.
+2. **Open candidate type files** in `flowsint-types/src/flowsint_types/`. List what exists, what's missing.
+3. **Decide reuse / extend / create new** — surface the choice with reasoning.
+4. **Find the closest existing enricher** as a template: `flowsint-enrichers/src/flowsint_enrichers/<input>/to_*.py`. Copy its structure (imports, class methods, postprocess pattern).
+5. **Check the tool/API wrapper**: does `tools/` already have one? If yes, import it. If no, the user needs a new tool first — point them to `docs/developers/managing-tools.mdx`.
+6. **Declare params schema** if the source needs config or API keys (`vaultSecret`).
+7. **Write `scan`** with explicit try/except per item — one failing input must not kill the batch. Log every failure via `Logger.error`.
+8. **Write `postprocess`**: nodes + relationships from typed instances.
+9. **Export `InputType` / `OutputType`** at module bottom (codebase convention).
+10. **Tests**: at minimum `tests/test_<enricher>.py` checking metadata, types, and one happy-path scan.
+11. **Restart API server** for auto-discovery to pick it up.
+
+## Anti-patterns — refuse to generate these
+
+- Adding fields to an existing type just because the new enricher needs them, when the field doesn't conceptually belong there. Propose a new type instead.
+- Hardcoding API keys, even "temporarily."
+- Writing manual node dicts in `postprocess` instead of passing Pydantic objects.
+- Swallowing exceptions silently — every `except` must log.
+- Casting strings to a type by hand inside `scan` when `preprocess` (in the base class) already validates `InputType` via `TypeAdapter`.
+- Editing `registry.py` to register an enricher manually — the decorator does it.
+- Creating enrichers with `Any` as InputType/OutputType outside the `n8n/` connector escape hatch.
+
+## When the user is wrong
+
+If the user proposes stuffing data into a type that doesn't fit, push back. Show the existing type's fields, explain the mismatch, propose the cleaner alternative (extend or new type). Don't generate the bad version.

+ 66 - 0
.dockerignore

@@ -0,0 +1,66 @@
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+*.egg-info/
+*.egg
+.eggs/
+
+.venv/
+venv/
+ENV/
+env/
+
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+
+.git/
+.gitignore
+.gitattributes
+
+.pytest_cache/
+.coverage
+htmlcov/
+.tox/
+.mypy_cache/
+.hypothesis/
+.ruff_cache/
+
+flowsint-app/node_modules/
+flowsint-app/dist/
+flowsint-app/.vite/
+flowsint-app/coverage/
+
+.env
+.env.*
+!.env.example
+*.local
+
+*.log
+logs/
+
+docker-compose*.yml
+Dockerfile.dev
+Dockerfile.optimized
+.dockerignore
+
+.github/
+.gitlab-ci.yml
+
+*.md
+!README.md
+docs/
+
+dist/
+build/
+
+*.bak
+*.tmp
+temp/
+tmp/
+Makefile

+ 12 - 0
.env.example

@@ -0,0 +1,12 @@
+NODE_ENV=production
+AUTH_SECRET=superscretchangeitplz
+# Generate your own master key by running: python3 -c "import os, base64; key = os.urandom(32); print('base64:' + base64.b64encode(key).decode('utf-8'))" 
+MASTER_VAULT_KEY_V1=base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM=
+NEO4J_URI_BOLT=bolt://neo4j:7687
+NEO4J_USERNAME=neo4j
+NEO4J_PASSWORD=password
+VITE_API_URL=http://127.0.0.1:5001
+# Comma-separated CORS allowed origins. Defaults to http://localhost:5173 (Vite dev) when unset.
+ALLOWED_ORIGINS=http://localhost:5173
+DATABASE_URL=postgresql://flowsint:flowsint@localhost:5433/flowsint
+REDIS_URL=redis://redis:6379/0

+ 175 - 0
.github/workflows/images.yml

@@ -0,0 +1,175 @@
+name: Build and Push Docker Images
+
+on:
+  push:
+    tags:
+      - "v*"
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: false
+
+jobs:
+  build-frontend:
+    name: Build Frontend
+    runs-on: ubuntu-latest
+    environment: production
+    permissions:
+      contents: read
+      packages: write
+      security-events: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v6
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Login to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract metadata
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: |
+            ${{ github.repository_owner }}/flowsint-app
+            ghcr.io/${{ github.repository_owner }}/flowsint-app
+          tags: |
+            type=semver,pattern={{version}}
+            type=semver,pattern={{major}}.{{minor}}
+            type=raw,value=latest
+
+      - name: Build and push
+        uses: docker/build-push-action@v6
+        with:
+          context: ./flowsint-app
+          file: ./flowsint-app/Dockerfile
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          provenance: true
+          sbom: true
+
+      - name: Run Trivy vulnerability scanner
+        id: trivy
+        uses: aquasecurity/trivy-action@v0.35.0
+        with:
+          image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ steps.meta.outputs.version }}
+          format: "sarif"
+          output: "trivy-frontend.sarif"
+          severity: "CRITICAL,HIGH"
+
+      - name: Upload Trivy scan results
+        uses: github/codeql-action/upload-sarif@v4
+        if: always() && steps.trivy.outcome == 'success'
+        with:
+          sarif_file: "trivy-frontend.sarif"
+
+  build-backend:
+    name: Build Backend
+    runs-on: ubuntu-latest
+    environment: production
+    permissions:
+      contents: read
+      packages: write
+      security-events: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v6
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Login to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract metadata
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: |
+            ${{ github.repository_owner }}/flowsint-api
+            ghcr.io/${{ github.repository_owner }}/flowsint-api
+          tags: |
+            type=semver,pattern={{version}}
+            type=semver,pattern={{major}}.{{minor}}
+            type=raw,value=latest
+
+      - name: Build and push
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          file: ./flowsint-api/Dockerfile
+          target: production
+          platforms: linux/amd64,linux/arm64
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          provenance: true
+          sbom: true
+
+      - name: Run Trivy vulnerability scanner
+        id: trivy
+        uses: aquasecurity/trivy-action@v0.35.0
+        with:
+          image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ steps.meta.outputs.version }}
+          format: "sarif"
+          output: "trivy-backend.sarif"
+          severity: "CRITICAL,HIGH"
+
+      - name: Upload Trivy scan results
+        uses: github/codeql-action/upload-sarif@v4
+        if: always() && steps.trivy.outcome == 'success'
+        with:
+          sarif_file: "trivy-backend.sarif"
+
+  security-summary:
+    name: Security Summary
+    runs-on: ubuntu-latest
+    needs: [build-frontend, build-backend]
+    if: always()
+    steps:
+      - name: Summary
+        run: |
+          echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
+          echo "" >> $GITHUB_STEP_SUMMARY
+          echo "| Image | Status |" >> $GITHUB_STEP_SUMMARY
+          echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
+          echo "| Frontend | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
+          echo "| Backend | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY
+          echo "" >> $GITHUB_STEP_SUMMARY
+          echo "Security scans uploaded to GitHub Security tab." >> $GITHUB_STEP_SUMMARY

+ 181 - 0
.gitignore

@@ -0,0 +1,181 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+.DS_Store
+/node_modules/
+# C extensions
+*.so
+*enricher_logs
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+/certs
+.claude/*
+!.claude/skills/
+.claude/skills/*
+!.claude/skills/flowsint-enricher-builder/

+ 4 - 0
.husky/commit-msg

@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+npx --no -- commitlint --edit ${1}

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.12

+ 67 - 0
.versionrc.json

@@ -0,0 +1,67 @@
+{
+  "header": "# Changelog\n\nAll notable changes to Flowsint will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n",
+  "types": [
+    {
+      "type": "feat",
+      "section": "Features"
+    },
+    {
+      "type": "fix",
+      "section": "Bug Fixes"
+    },
+    {
+      "type": "perf",
+      "section": "Performance Improvements"
+    },
+    {
+      "type": "refactor",
+      "section": "Code Refactoring",
+      "hidden": false
+    },
+    {
+      "type": "docs",
+      "section": "Documentation",
+      "hidden": false
+    },
+    {
+      "type": "style",
+      "hidden": true
+    },
+    {
+      "type": "chore",
+      "hidden": true
+    },
+    {
+      "type": "test",
+      "hidden": true
+    },
+    {
+      "type": "build",
+      "section": "Build System",
+      "hidden": false
+    },
+    {
+      "type": "ci",
+      "hidden": true
+    }
+  ],
+  "packageFiles": [
+    {
+      "filename": "flowsint-app/package.json",
+      "type": "json"
+    }
+  ],
+  "bumpFiles": [
+    {
+      "filename": "flowsint-app/package.json",
+      "type": "json"
+    },
+    {
+      "filename": "pyproject.toml",
+      "updater": "scripts/pyproject-updater.js"
+    }
+  ],
+  "scripts": {
+    "postbump": "node scripts/sync-versions.js $(node -p \"require('./flowsint-app/package.json').version\")"
+  }
+}

+ 6 - 0
CHANGELOG.md

@@ -0,0 +1,6 @@
+# Changelog
+
+All notable changes to Flowsint will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

+ 29 - 0
DISCLAIMER.md

@@ -0,0 +1,29 @@
+# Disclaimer
+
+This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement.
+
+## No Responsibility
+
+The author(s) and contributor(s) of this tool assume no responsibility or liability for any damages, losses, or consequences that may result from the use or misuse of this software. This includes, but is not limited to:
+
+- Any damage to systems, networks, or data
+- Any legal consequences resulting from unauthorized or improper use
+- Any business losses or operational disruptions
+- Any security incidents or breaches
+
+## User Responsibility
+
+By using this tool, you acknowledge and agree that:
+
+- You are solely responsible for your use of this software
+- You will use this tool in compliance with all applicable laws and regulations
+- You will obtain proper authorization before conducting any security testing or reconnaissance activities
+- You understand the potential risks and legal implications of using security tools
+
+## Intended Use
+
+This tool is intended for legitimate security research, authorized penetration testing, and defensive security purposes only. Any use of this tool for malicious purposes or without proper authorization is strictly prohibited and may be illegal.
+
+## No Support Guarantee
+
+The author(s) provide this software without any guarantee of support, updates, or maintenance. Use at your own risk.

+ 54 - 0
ETHICS.md

@@ -0,0 +1,54 @@
+# Ethics and Usage Principles
+
+## License
+
+Flowsint is distributed under the **Apache License 2.0** (effective January 25, 2026).
+
+Code contributed prior to January 25, 2026 was originally licensed under AGPL-3.0
+and has been relicensed to Apache 2.0 with the written consent of all contributors.
+
+## Ethical Foundation
+
+The development and use of Flowsint are guided by the principles of the [Hippocratic License](https://firstdonoharm.dev/):
+
+> **The software must not be used in ways that violate fundamental human rights.**
+
+## Ethical Commitment
+
+By using or contributing to Flowsint, all individuals and organizations are expected to:
+
+* Respect and uphold universal human rights
+* Use the software responsibly, transparently, and ethically
+* Refrain from any activity that harms human dignity or privacy
+* Promote constructive, verifiable, and socially beneficial use of technology
+
+## Lawful and Responsible Use
+
+Flowsint is designed **strictly for lawful, ethical investigation and research purposes**.
+It was created to assist:
+
+* Cybersecurity researchers and analysts
+* Journalists and OSINT investigators
+* Law enforcement or fraud investigation teams
+* Organizations conducting internal threat intelligence or digital risk analysis
+
+### Prohibited Uses
+
+Flowsint **must not be used** for:
+
+* Unauthorized intrusion, surveillance, or data collection
+* Harassment, doxxing, blackmail, or targeting of individuals
+* Political manipulation, disinformation, or privacy violations
+* Any activity that contravenes international human rights standards
+
+These examples are **non-exhaustive** and may evolve according to ethical and legal norms.
+
+## Reporting Misuse
+
+If you become aware of any use that contradicts these principles, please report it to:
+📧 **[contact@flowsint.io](mailto:contact@flowsint.io)**
+
+## Responsibility Statement
+
+The developers and maintainers of Flowsint are committed to upholding these ethical principles throughout the project’s life cycle.
+However, in accordance with the Apache License 2.0, **Flowsint is provided "as is," without warranty**, and its authors **cannot be held liable for misuse or unlawful activity conducted by third parties**.

+ 201 - 0
LICENSE

@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to the Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2025-2026 Reconurge
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 234 - 0
Makefile

@@ -0,0 +1,234 @@
+PROJECT_ROOT := $(shell pwd)
+
+COMPOSE_DEV    := docker compose -f docker-compose.dev.yml
+COMPOSE_PROD   := docker compose -f docker-compose.prod.yml
+COMPOSE_DEPLOY := docker compose -f docker-compose.deploy.yml
+
+.PHONY: \
+	dev prod deploy \
+	build-dev build-prod \
+	up-dev up-prod up-deploy down \
+	infra-dev infra-prod infra-stop-dev infra-stop-prod \
+	migrate-dev migrate-prod \
+	alembic-upgrade alembic-downgrade alembic-revision \
+	api frontend celery \
+	test install clean check-env open-browser-dev open-browser-prod \
+	logs-dev logs-prod logs-deploy status \
+	regenerate-router
+
+ENV_DIRS := . flowsint-api flowsint-core flowsint-app
+
+check-env:
+	@echo "Checking .env files..."
+	@for dir in $(ENV_DIRS); do \
+		env_file="$$dir/.env"; \
+		env_example="$(PROJECT_ROOT)/.env.example"; \
+		if [ ! -f "$$env_file" ]; then \
+			cp "$$env_example" "$$env_file"; \
+			echo "Created $$env_file"; \
+		fi; \
+	done
+
+dev:
+	@echo "Starting DEV environment..."
+	$(MAKE) check-env
+	$(MAKE) build-dev
+	$(MAKE) up-dev
+	$(MAKE) open-browser-dev
+	$(COMPOSE_DEV) logs -f
+
+build-dev:
+	@echo "Building DEV images..."
+	$(COMPOSE_DEV) build
+
+up-dev:
+	$(COMPOSE_DEV) up -d
+
+infra-dev:
+	@echo "Starting DEV infra (postgres / redis / neo4j)..."
+	$(COMPOSE_DEV) up -d postgres redis neo4j
+
+infra-stop-dev:
+	@echo "Stopping DEV infra..."
+	$(COMPOSE_DEV) stop postgres redis neo4j
+
+logs-dev:
+	$(COMPOSE_DEV) logs -f
+
+open-browser-dev:
+	@echo "Waiting for frontend on port 5173..."
+	@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
+	@open http://localhost:5173 2>/dev/null || \
+	 xdg-open http://localhost:5173 2>/dev/null || \
+	 echo "Frontend ready at http://localhost:5173"
+
+prod:
+	@echo "Starting PROD environment..."
+	$(MAKE) check-env
+	$(MAKE) build-prod
+	$(MAKE) up-prod
+	@echo ""
+	@echo "Production started!"
+	@echo "  Frontend: http://localhost:5173"
+	@echo "  API:      http://localhost:5001"
+
+build-prod:
+	@echo "Building PROD images..."
+	$(COMPOSE_PROD) build
+
+up-prod:
+	$(COMPOSE_PROD) up -d
+
+infra-prod:
+	@echo "Starting PROD infra (postgres / redis / neo4j)..."
+	$(COMPOSE_PROD) up -d postgres redis neo4j
+
+infra-stop-prod:
+	@echo "Stopping PROD infra..."
+	$(COMPOSE_PROD) stop postgres redis neo4j
+
+logs-prod:
+	$(COMPOSE_PROD) logs -f
+
+open-browser-prod:
+	@echo "Waiting for frontend on port 80 (Traefik)..."
+	@bash -c 'until curl -s http://localhost > /dev/null 2>&1; do sleep 2; done'
+	@open http://localhost 2>/dev/null || \
+	 xdg-open http://localhost 2>/dev/null || \
+	 echo "Frontend ready at http://localhost"
+
+deploy:
+	@echo "Starting DEPLOY environment (GHCR images)..."
+	$(MAKE) check-env
+	$(COMPOSE_DEPLOY) pull
+	$(COMPOSE_DEPLOY) up -d
+	@echo ""
+	@echo "Deploy started!"
+	@echo "  Frontend: http://localhost (via Traefik)"
+	@echo "  API:      http://localhost/api"
+
+up-deploy:
+	$(COMPOSE_DEPLOY) up -d
+
+logs-deploy:
+	$(COMPOSE_DEPLOY) logs -f
+
+migrate-dev:
+	@echo "Running DEV migrations..."
+	@if ! $(COMPOSE_DEV) ps -q neo4j | grep -q .; then \
+		echo "Neo4j not running → starting DEV infra"; \
+		$(COMPOSE_DEV) up -d --wait neo4j; \
+	fi
+	yarn migrate
+
+migrate-prod:
+	@echo "⚠️  Running PROD migrations"
+	@echo "This will ALTER production data."
+	@read -p "Type 'prod' to continue: " confirm; \
+	if [ "$$confirm" != "prod" ]; then \
+		echo "Aborted."; exit 1; \
+	fi
+	yarn migrate
+
+alembic-upgrade:
+	@echo "Running Alembic migrations (upgrade head)..."
+	cd $(PROJECT_ROOT)/flowsint-api && uv run alembic upgrade head
+
+alembic-downgrade:
+	@echo "Rolling back last Alembic migration..."
+	cd $(PROJECT_ROOT)/flowsint-api && uv run alembic downgrade -1
+
+alembic-revision:
+	@if [ -z "$(m)" ]; then \
+		echo "Usage: make alembic-revision m=\"your migration message\""; exit 1; \
+	fi
+	@echo "Creating new Alembic migration: $(m)"
+	cd $(PROJECT_ROOT)/flowsint-api && uv run alembic revision --autogenerate -m "$(m)"
+
+api:
+	cd $(PROJECT_ROOT)/flowsint-api && \
+	uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
+
+frontend:
+	cd $(PROJECT_ROOT)/flowsint-app && yarn dev
+
+celery:
+	cd $(PROJECT_ROOT)/flowsint-api && \
+	uv run celery -A flowsint_core.core.celery \
+	worker --loglevel=info --pool=threads --concurrency=10
+
+test:
+	cd flowsint-types && uv run pytest
+	cd flowsint-core && uv run pytest
+	cd flowsint-enrichers && uv run pytest
+
+install:
+	$(MAKE) infra-dev
+	uv sync
+	cd flowsint-api && uv run alembic upgrade head
+
+status:
+	@echo "=== DEV Containers ==="
+	@$(COMPOSE_DEV) ps 2>/dev/null || echo "No DEV containers"
+	@echo ""
+	@echo "=== PROD Containers ==="
+	@$(COMPOSE_PROD) ps 2>/dev/null || echo "No PROD containers"
+
+down:
+	-$(COMPOSE_DEV) down
+	-$(COMPOSE_PROD) down
+	-$(COMPOSE_DEPLOY) down
+
+clean:
+	@echo "This will remove ALL Docker data. Continue? [y/N]"
+	@read confirm; \
+	if [ "$$confirm" != "y" ]; then exit 1; fi
+	-$(COMPOSE_DEV) down -v --rmi all --remove-orphans
+	-$(COMPOSE_PROD) down -v --rmi all --remove-orphans
+	-$(COMPOSE_DEPLOY) down -v --rmi all --remove-orphans
+	rm -rf flowsint-app/node_modules
+	rm -rf .venv
+
+regenerate-router:
+	@echo "Regenerating flowsint-app/src/routeTree.gen.ts"
+	cd $(PROJECT_ROOT)/flowsint-app && npx tsr generate
+
+help:
+	@echo "Flowsint Makefile"
+	@echo ""
+	@echo "Development:"
+	@echo "  make dev          - Start DEV environment (local build, hot-reload)"
+	@echo "  make build-dev    - Build DEV images"
+	@echo "  make up-dev       - Start DEV containers"
+	@echo "  make logs-dev     - Follow DEV logs"
+	@echo "  make infra-dev    - Start only infra (postgres/redis/neo4j)"
+	@echo ""
+	@echo "Production (local build):"
+	@echo "  make prod         - Start PROD environment (local build + Traefik)"
+	@echo "  make build-prod   - Build PROD images"
+	@echo "  make up-prod      - Start PROD containers"
+	@echo "  make logs-prod    - Follow PROD logs"
+	@echo ""
+	@echo "Deploy (GHCR images):"
+	@echo "  make deploy       - Start with GHCR images (no build)"
+	@echo "  make up-deploy    - Start DEPLOY containers"
+	@echo "  make logs-deploy  - Follow DEPLOY logs"
+	@echo ""
+	@echo "Local (no Docker):"
+	@echo "  make api          - Run API locally"
+	@echo "  make frontend     - Run frontend locally"
+	@echo "  make celery       - Run Celery worker locally"
+	@echo ""
+	@echo "Migrations:"
+	@echo "  make migrate-dev           - Run Neo4j DEV migrations"
+	@echo "  make migrate-prod          - Run Neo4j PROD migrations"
+	@echo "  make alembic-upgrade       - Run Alembic migrations (upgrade head)"
+	@echo "  make alembic-downgrade     - Rollback last Alembic migration"
+	@echo "  make alembic-revision m=.. - Create new Alembic migration"
+	@echo ""
+	@echo "Utilities:"
+	@echo "  make status       - Show container status"
+	@echo "  make down         - Stop all containers"
+	@echo "  make clean        - Remove all Docker data"
+	@echo "  make install      - Install dependencies locally"
+	@echo "  make test         - Run tests"

+ 30 - 0
NOTICE

@@ -0,0 +1,30 @@
+Flowsint
+Copyright 2025-2026 Reconurge
+
+This product includes software developed by Reconurge.
+https://github.com/reconurge/flowsint
+
+Licensed under the Apache License, Version 2.0.
+
+===============================================================================
+LICENSING HISTORY
+===============================================================================
+
+Effective January 25, 2026, Flowsint is licensed under the Apache License 2.0.
+
+All code contributed prior to January 25, 2026 was originally released under
+the GNU Affero General Public License v3.0 (AGPL-3.0-or-later). This code has
+been relicensed to Apache License 2.0 with the explicit written consent of all
+contributors.
+
+The historical Git commits retain the original AGPL-3.0 license references for
+archival purposes. Users obtaining code from Git history prior to commit
+[LICENSE_MIGRATION_COMMIT] should note that such code was contributed under
+AGPL-3.0 terms, but has since been relicensed.
+
+For the avoidance of doubt:
+- Code in releases from January 25, 2026 onward: Apache License 2.0
+- Code in Git history prior to January 25, 2026: Originally AGPL-3.0,
+  relicensed to Apache License 2.0 with contributor consent
+
+===============================================================================

+ 232 - 56
README.md

@@ -1,92 +1,268 @@
-# flowsint-cn
+# Flowsint
 
+[![License](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](./LICENSE)
+[![Ethical Software](https://img.shields.io/badge/ethical-use-blue.svg)](./ETHICS.md)
+[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
+[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)
+[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/aST9HMQr)
 
 
-## Getting started
+Flowsint is an open-source OSINT graph exploration tool designed for ethical investigation, transparency, and verification.
 
-To make it easy for you to get started with GitLab, here's a list of recommended next steps.
+**Ethics:** Please read [ETHICS.md](./ETHICS.md) for responsible use guidelines.
 
-Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
+<img width="1439" height="899" alt="hero-dark" src="https://github.com/user-attachments/assets/01eb128e-bef4-486e-9276-c4da58f829ae" />
 
-## Add your files
 
-- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
-- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
+https://github.com/user-attachments/assets/eaabfa81-d7b3-414d-8cf7-f69b4e37bab6
 
+
+https://github.com/user-attachments/assets/7457d94a-cf1d-4a97-949f-f9b1d8d92644
+
+
+https://github.com/user-attachments/assets/65c3f26e-7132-4853-be45-21b8933688bd
+
+
+## Contributing
+
+Flowsint is still in early development and definetly needs the help of the community! Feel free to raise issues, propose features, etc.
+
+## Get started
+
+Don't want to read ? Got it. Here's your install instructions:
+
+#### 1. Install pre-requisites
+
+- Docker
+- Make
+
+#### 2. Run install command
+
+```bash
+git clone https://github.com/reconurge/flowsint.git
+cd flowsint
+make prod
+```
+
+Then go to [http://localhost:5173/register](http://localhost:5173/register) and create an account. There are no credentials or account by default.
+
+
+> ✅ OSINT investigations need a high level of privacy. Everything is stored on your machine.
+
+
+## What is it?
+
+Flowsint is a graph-based investigation tool focused on reconnaissance and OSINT (Open Source Intelligence). It allows you to explore relationships between entities through a visual graph interface and automated enrichers.
+
+### Available Enrichers
+
+**Domain Enrichers**
+- Reverse DNS Resolution - Find domains pointing to an IP
+- DNS Resolution - Resolve domain to IP addresses
+- Subdomain Discovery - Enumerate subdomains
+- WHOIS Lookup - Get domain registration information
+- Domain to Website - Convert domain to website entity
+- Domain to Root Domain - Extract root domain
+- Domain to ASN - Find ASN associated with domain
+- Domain History - Retrieve historical domain data
+
+**IP Enrichers**
+- IP Information - Get geolocation and network details
+- IP to ASN - Find ASN for IP address
+
+**ASN Enrichers**
+- ASN to CIDRs - Get IP ranges for an ASN
+
+**CIDR Enrichers**
+- CIDR to IPs - Enumerate IPs in a range
+
+**Social Media Enrichers**
+- Maigret - Username search across social platforms
+
+**Organization Enrichers**
+- Organization to ASN - Find ASNs owned by organization
+- Organization Information - Get company details
+- Organization to Domains - Find domains owned by organization
+
+**Cryptocurrency Enrichers**
+- Wallet to Transactions - Get transaction history
+- Wallet to NFTs - Find NFTs owned by wallet
+
+**Website Enrichers**
+- Website Crawler - Crawl and map website structure
+- Website to Links - Extract all links
+- Website to Domain - Extract domain from URL
+- Website to Webtrackers - Identify tracking scripts
+- Website to Text - Extract text content
+
+**Email Enrichers**
+- Email to Gravatar - Find Gravatar profile
+- Email to Breaches - Check data breach databases
+- Email to Domains - Find associated domains
+
+**Phone Enrichers**
+- Phone to Breaches - Check phone number in breaches
+
+**Individual Enrichers**
+- Individual to Organization - Find organizational affiliations
+- Individual to Domains - Find domains associated with person
+
+**Integration Enrichers**
+- N8n Connector - Connect to N8n workflows
+
+## Project structure
+
+The project is organized into autonomous modules:
+
+### Core modules
+
+- **flowsint-core**: Core utilities, orchestrator, vault, celery tasks, and base classes
+- **flowsint-types**: Pydantic models and type definitions
+- **flowsint-enrichers**: Enricher modules, scanning logic, and tools
+- **flowsint-api**: FastAPI server, API routes, and schemas only
+- **flowsint-app**: Frontend application
+
+### Module dependencies
+
+```
+flowsint-app (frontend)
+    ↓
+flowsint-api (API server)
+    ↓
+flowsint-core (orchestrator, tasks, vault)
+    ↓
+flowsint-enrichers (enrichers & tools)
+    ↓
+flowsint-types (types)
 ```
-cd existing_repo
-git remote add origin https://www.gitcc.com/xiejiale/flowsint-cn.git
-git branch -M main
-git push -uf origin main
+
+## Development setup
+
+### Prerequisites
+
+- Docker
+
+### Run
+
+Make sure you have **Make** installed.
+
+```bash
+make dev
 ```
 
-## Integrate with your tools
+### Development
+
+The app is accessible at [http://localhost:5173](http://localhost:5173).
+
+## Module details
+
+### flowsint-core
 
-- [ ] [Set up project integrations](https://www.gitcc.com/xiejiale/flowsint-cn/-/settings/integrations)
+Core utilities and base classes used by all other modules:
 
-## Collaborate with your team
+- Database connections (PostgreSQL, Neo4j)
+- Authentication and authorization
+- Logging and event handling
+- Configuration management
+- Base classes for enrichers and tools
+- Utility functions
 
-- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
-- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
-- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
-- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
-- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
+### flowsint-types
 
-## Test and Deploy
+Pydantic models for all data types:
 
-Use the built-in continuous integration in GitLab.
+- Domain, IP, ASN, CIDR
+- Individual, Organization, Email, Phone
+- Website, Social profiles, Credentials
+- Crypto wallets, Transactions, NFTs
+- And many more...
 
-- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
-- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
-- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
-- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
-- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
+### flowsint-enrichers
 
-***
+Enricher modules that process data:
 
-# Editing this README
+- Domain enrichers (subdomains, WHOIS, resolution)
+- IP enrichers (geolocation, ASN lookup)
+- Social media enrichers (Maigret, Sherlock)
+- Email enrichers (breaches, Gravatar)
+- Crypto enrichers (transactions, NFTs)
+- And many more...
 
-When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
+### flowsint-api
 
-## Suggestions for a good README
-Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
+FastAPI server providing:
 
-## Name
-Choose a self-explaining name for your project.
+- REST API endpoints
+- Authentication and user management
+- Graph database integration
+- Real-time event streaming
 
-## Description
-Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
+### flowsint-app
 
-## Badges
-On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
+Frontend application.
 
-## Visuals
-Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
+- Modern and UI friendly interface
+- Built for performance (no lag even on thousands of nodes)
 
-## Installation
-Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
+## Development workflow
 
-## Usage
-Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
+1. **Adding new types**: Add to `flowsint-types` module
+2. **Adding new enrichers**: Add to `flowsint-enrichers` module
+3. **Adding new API endpoints**: Add to `flowsint-api` module
+4. **Adding new utilities**: Add to `flowsint-core` module
 
-## Support
-Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
+## Testing
 
-## Roadmap
-If you have ideas for releases in the future, it is a good idea to list them in the README.
+Each module has its own (incomplete) test suite:
+
+```bash
+# Test core module
+cd flowsint-core
+uv run pytest
+
+# Test types module
+cd ../flowsint-types
+uv run pytest
+
+# Test enrichers module
+cd ../flowsint-enrichers
+uv run pytest
+
+# Test API module
+cd ../flowsint-api
+uv run pytest
+```
 
 ## Contributing
-State if you are open to contributions and what your requirements are for accepting them.
 
-For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
+1. Follow the modular structure
+2. Use Poetry for dependency management
+3. Write tests for new functionality
+4. Update documentation as needed
+
+
+---
+
+## ⚖️ Legal & Ethical Use
+
+**Ethics:** Please read [ETHICS.md](./ETHICS.md) for responsible use guidelines.
+
+Flowsint is designed **strictly for lawful, ethical investigation and research purposes**.
+
+It was created to assist:
+- Cybersecurity researchers and analysts
+- Journalists and OSINT investigators
+- Law enforcement or fraud investigation teams
+- Organizations conducting internal threat intelligence or digital risk analysis
 
-You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
+**Flowsint must not be used for:**
+- Unauthorized intrusion, surveillance, or data collection
+- Harassment, doxxing, or targeting of individuals
+- Political manipulation, misinformation, or violation of privacy laws
 
-## Authors and acknowledgment
-Show your appreciation to those who have contributed to the project.
+Any misuse of this software is strictly prohibited and goes against the ethical principles defined in [ETHICS.md](./ETHICS.md).
 
-## License
-For open source projects, say how it is licensed.
+## ❤️ Support
 
-## Project status
-If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
+[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)

+ 24 - 0
commitlint.config.js

@@ -0,0 +1,24 @@
+export default {
+  extends: ['@commitlint/config-conventional'],
+  rules: {
+    'type-enum': [
+      2,
+      'always',
+      [
+        'feat',     // New feature
+        'fix',      // Bug fix
+        'docs',     // Documentation only changes
+        'style',    // Code style changes (formatting, etc)
+        'refactor', // Code refactoring
+        'perf',     // Performance improvements
+        'test',     // Adding or updating tests
+        'build',    // Build system or dependencies
+        'ci',       // CI/CD changes
+        'chore',    // Other changes that don't modify src
+        'revert',   // Revert previous commit
+      ],
+    ],
+    'scope-case': [2, 'always', 'kebab-case'],
+    'subject-case': [0], // Allow any case for subject
+  },
+};

+ 176 - 0
docker-compose.dev.yml

@@ -0,0 +1,176 @@
+name: flowsint-dev
+
+services:
+  postgres:
+    image: postgres:15
+    container_name: flowsint-postgres-dev
+    restart: always
+    environment:
+      POSTGRES_USER: flowsint
+      POSTGRES_PASSWORD: flowsint
+      POSTGRES_DB: flowsint
+    ports:
+      - "5433:5432"
+    volumes:
+      - pg_data_dev:/var/lib/postgresql/data
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U flowsint"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  redis:
+    image: redis:alpine
+    container_name: flowsint-redis-dev
+    ports:
+      - "6379:6379"
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  neo4j:
+    image: neo4j:5
+    container_name: flowsint-neo4j-dev
+    ports:
+      - "7474:7474" # Web UI
+      - "7687:7687" # Bolt
+    environment:
+      - NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
+      - NEO4J_PLUGINS=["apoc"]
+      - NEO4J_apoc_export_file_enabled=true
+      - NEO4J_apoc_import_file_enabled=true
+      - NEO4J_apoc_import_file_use__neo4j__config=true
+    volumes:
+      - neo4j_data_dev:/data
+      - neo4j_logs_dev:/logs
+      - neo4j_import_dev:/var/lib/neo4j/import
+      - neo4j_plugins_dev:/plugins
+    restart: unless-stopped
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
+      interval: 5s
+      timeout: 5s
+      retries: 10
+
+  api:
+    build:
+      context: .
+      dockerfile: flowsint-api/Dockerfile
+      target: dev
+    container_name: flowsint-api-dev
+    restart: unless-stopped
+    ports:
+      - "5001:5001"
+    volumes:
+      - ./flowsint-api:/app/flowsint-api
+      - /app/flowsint-api/.venv
+      - ./flowsint-core:/app/flowsint-core
+      - ./flowsint-types:/app/flowsint-types
+      - ./flowsint-enrichers:/app/flowsint-enrichers
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
+      - NEO4J_URI_BOLT=bolt://neo4j:7687
+      - NEO4J_USERNAME=${NEO4J_USERNAME}
+      - NEO4J_PASSWORD=${NEO4J_PASSWORD}
+      - AUTH_SECRET=${AUTH_SECRET}
+      - MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
+      - REDIS_URL=redis://redis:6379/0
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      neo4j:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "curl -f http://localhost:5001/health || exit 1"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+    networks:
+      - flowsint_network
+
+  celery:
+    build:
+      context: .
+      dockerfile: flowsint-api/Dockerfile
+      target: dev
+    container_name: flowsint-celery-dev
+    restart: unless-stopped
+    command: celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
+    volumes:
+      - ./flowsint-api:/app/flowsint-api
+      - /app/flowsint-api/.venv
+      - ./flowsint-core:/app/flowsint-core
+      - ./flowsint-types:/app/flowsint-types
+      - ./flowsint-enrichers:/app/flowsint-enrichers
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
+      - NEO4J_URI_BOLT=bolt://neo4j:7687
+      - NEO4J_USERNAME=${NEO4J_USERNAME}
+      - NEO4J_PASSWORD=${NEO4J_PASSWORD}
+      - MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
+      - REDIS_URL=redis://redis:6379/0
+      - SKIP_MIGRATIONS=true
+      - AUTH_SECRET=${AUTH_SECRET}
+    healthcheck:
+      # Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
+      # Use celery's own ping primitive instead.
+      test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 30s
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      neo4j:
+        condition: service_healthy
+      api:
+        condition: service_healthy
+    networks:
+      - flowsint_network
+
+  app:
+    build:
+      context: ./flowsint-app
+      dockerfile: Dockerfile.dev
+    container_name: flowsint-app-dev
+    ports:
+      - "5173:5173"
+    volumes:
+      - ./flowsint-app:/app
+      - /app/node_modules
+    environment:
+      - VITE_API_URL=${VITE_API_URL}
+    depends_on:
+      api:
+        condition: service_healthy
+    networks:
+      - flowsint_network
+    stdin_open: true
+    tty: true
+
+networks:
+  flowsint_network:
+    name: flowsint_network_dev
+    driver: bridge
+
+volumes:
+  pg_data_dev:
+  neo4j_data_dev:
+  neo4j_logs_dev:
+  neo4j_import_dev:
+  neo4j_plugins_dev:

+ 172 - 0
docker-compose.prod.yml

@@ -0,0 +1,172 @@
+name: flowsint-prod
+
+services:
+  postgres:
+    image: postgres:15-alpine
+    container_name: flowsint-postgres-prod
+    restart: always
+    environment:
+      POSTGRES_USER: ${POSTGRES_USER:-flowsint}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flowsint}
+      POSTGRES_DB: ${POSTGRES_DB:-flowsint}
+    ports:
+      - "5433:5432"
+    volumes:
+      - pg_data_prod:/var/lib/postgresql/data
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flowsint}"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  redis:
+    image: redis:7-alpine
+    container_name: flowsint-redis-prod
+    restart: always
+    ports:
+      - "6379:6379"
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  neo4j:
+    image: neo4j:5
+    container_name: flowsint-neo4j-prod
+    restart: always
+    ports:
+      - "7474:7474"
+      - "7687:7687"
+    environment:
+      - NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
+      - NEO4J_PLUGINS=["apoc"]
+      - NEO4J_apoc_export_file_enabled=true
+      - NEO4J_apoc_import_file_enabled=true
+      - NEO4J_apoc_import_file_use__neo4j__config=true
+    volumes:
+      - neo4j_data_prod:/data
+      - neo4j_logs_prod:/logs
+      - neo4j_import_prod:/var/lib/neo4j/import
+      - neo4j_plugins_prod:/plugins
+    networks:
+      - flowsint_network
+    healthcheck:
+      test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
+      interval: 5s
+      timeout: 5s
+      retries: 10
+
+  api:
+    build:
+      context: .
+      dockerfile: flowsint-api/Dockerfile
+      target: production
+    container_name: flowsint-api-prod
+    restart: always
+    ports:
+      - "5001:5001"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
+      - NEO4J_URI_BOLT=bolt://neo4j:7687
+      - NEO4J_USERNAME=${NEO4J_USERNAME}
+      - NEO4J_PASSWORD=${NEO4J_PASSWORD}
+      - AUTH_SECRET=${AUTH_SECRET}
+      - MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
+      - REDIS_URL=redis://redis:6379/0
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      neo4j:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "curl -f http://localhost:5001/health || exit 1"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+    networks:
+      - flowsint_network
+
+  celery:
+    build:
+      context: .
+      dockerfile: flowsint-api/Dockerfile
+      target: production
+    container_name: flowsint-celery-prod
+    restart: always
+    command:
+      [
+        "celery",
+        "-A",
+        "flowsint_core.core.celery",
+        "worker",
+        "--loglevel=info",
+        "--pool=threads",
+        "--concurrency=10",
+      ]
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
+      - NEO4J_URI_BOLT=bolt://neo4j:7687
+      - NEO4J_USERNAME=${NEO4J_USERNAME}
+      - NEO4J_PASSWORD=${NEO4J_PASSWORD}
+      - MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
+      - REDIS_URL=redis://redis:6379/0
+      - SKIP_MIGRATIONS=true
+      - AUTH_SECRET=${AUTH_SECRET}
+    healthcheck:
+      # Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
+      # Use celery's own ping primitive instead.
+      test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 30s
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+      neo4j:
+        condition: service_healthy
+      api:
+        condition: service_healthy
+    networks:
+      - flowsint_network
+
+  app:
+    build:
+      context: ./flowsint-app
+      dockerfile: Dockerfile
+      args:
+        - VITE_API_URL=${VITE_API_URL}
+    container_name: flowsint-app-prod
+    restart: always
+    ports:
+      - "5173:8080"
+    networks:
+      - flowsint_network
+    depends_on:
+      api:
+        condition: service_healthy
+
+networks:
+  flowsint_network:
+    name: flowsint_network_prod
+    driver: bridge
+
+volumes:
+  pg_data_prod:
+  neo4j_data_prod:
+  neo4j_logs_prod:
+  neo4j_import_prod:
+  neo4j_plugins_prod:

+ 84 - 0
docker-compose.yml

@@ -0,0 +1,84 @@
+name: flowsint
+
+services:
+  # PostgreSQL database
+  postgres:
+    image: postgres:15
+    container_name: flowsint-postgres
+    restart: always
+    environment:
+      POSTGRES_USER: flowsint
+      POSTGRES_PASSWORD: flowsint
+      POSTGRES_DB: flowsint
+    ports:
+      - "5433:5432"
+    volumes:
+      - pg_data:/var/lib/postgresql/data
+    networks:
+      - flowsint_network
+      
+  # Redis for Celery & cache
+  redis:
+    image: redis:alpine
+    container_name: redis-cache
+    ports:
+      - "6379:6379"
+    networks:
+      - flowsint_network
+
+  # Neo4j graph database
+  neo4j:
+    image: neo4j:5
+    container_name: flowsint-neo4j
+    ports:
+      - "7474:7474"  # Web UI
+      - "7687:7687"  # Bolt
+    environment:
+      - NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
+      - NEO4J_PLUGINS=["apoc"]
+      - NEO4J_apoc_export_file_enabled=true
+      - NEO4J_apoc_import_file_enabled=true
+      - NEO4J_apoc_import_file_use__neo4j__config=true
+    volumes:
+      - neo4j_data:/data
+      - neo4j_logs:/logs
+      - neo4j_import:/var/lib/neo4j/import
+      - neo4j_plugins:/plugins
+    healthcheck:
+      test: cypher-shell -u ${NEO4J_USERNAME} -p ${NEO4J_PASSWORD} "RETURN 1"
+      interval: 5s
+      timeout: 5s
+      retries: 10
+    restart: unless-stopped
+    networks:
+      - flowsint_network
+
+  app:
+    build:
+      context: ./flowsint-app
+      dockerfile: Dockerfile.dev
+    container_name: flowsint-app
+    ports:
+      - "5173:5173"
+    volumes:
+      - ./flowsint-app:/app
+      - app_node_modules:/app/node_modules
+    environment:
+      - VITE_API_URL=${VITE_API_URL}
+    networks:
+      - flowsint_network
+    stdin_open: true
+    tty: true
+
+networks:
+  flowsint_network:
+    name: flowsint_network
+    driver: bridge
+
+volumes:
+  pg_data:
+  neo4j_data:
+  neo4j_logs:
+  neo4j_import:
+  neo4j_plugins:
+  app_node_modules:

+ 18 - 0
docs/developers/getting-started.mdx

@@ -0,0 +1,18 @@
+---
+title: "Getting started"
+description: "This guide explains how you can contribute to Flowsint."
+category: "Developers"
+order: 7
+author: "Flowsint Team"
+tags: ["tutorial", "developers", "getting-started"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## Contributing
+
+Your contribution to Flowsint is highly encouraged ! 
+
+You can create your own [Types](/docs/developers/managing-types), [Tools](/docs/developers/managing-tools), [Enrichers](/docs/developers/managing-enrichers) and Flows, and sharing them by proposing [pull requests](https://github.com/reconurge/flowsint/pulls).
+
+Before diving in, you may also want to understand the [Graph format](/docs/developers/graph-format) to learn how nodes and edges are structured in the frontend visualization.

+ 244 - 0
docs/developers/graph-format.mdx

@@ -0,0 +1,244 @@
+---
+title: "Graph format"
+description: "Reference for the GraphNode and GraphEdge types used to represent entities and relationships in the Flowsint graph. Understanding these structures is essential for working with the frontend visualization and the graph database layer."
+category: "Developers"
+order: 11
+author: "Flowsint Team"
+tags: ["tutorial", "developers", "graph", "nodes", "edges"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## Overview
+
+Flowsint's graph visualization is built on two core data structures: **GraphNode** (representing entities) and **GraphEdge** (representing relationships between entities). These types are defined in `flowsint-app/src/types/graph.ts` and are used throughout the frontend for rendering, interaction, and data management.
+
+Understanding these structures is important when:
+- Building enrichers that create nodes and relationships
+- Working on the frontend visualization
+- Debugging graph rendering issues
+- Extending the graph with new visual features
+
+## GraphNode
+
+A `GraphNode` represents a single entity in the graph (e.g., a domain, an IP address, a person). Every node created by an enricher's `create_node()` method ends up as a `GraphNode` in the frontend.
+
+```typescript
+type GraphNode = {
+  id: string
+  nodeType: string
+  nodeLabel: string
+  nodeProperties: NodeProperties
+  nodeSize: number
+  nodeColor: string | null
+  nodeIcon: keyof typeof LucideIcons | null
+  nodeImage: string | null
+  nodeFlag: flagColor | null
+  nodeShape: NodeShape | null
+  nodeMetadata: NodeMetadata
+  x: number
+  y: number
+  val?: number
+  neighbors?: any[]
+  links?: any[]
+}
+```
+
+### Field reference
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `id` | `string` | Unique identifier for the node. Derived from the type's primary field value. |
+| `nodeType` | `string` | The entity type (e.g., `"Domain"`, `"Ip"`, `"Email"`). Maps to the Pydantic type class name. |
+| `nodeLabel` | `string` | Human-readable label displayed on the node. Set by the type's `compute_label()` method. |
+| `nodeProperties` | `NodeProperties` | All properties of the entity as key-value pairs. Contains the serialized fields from the Pydantic type. |
+| `nodeSize` | `number` | Visual size of the node in the graph. |
+| `nodeColor` | `string \| null` | Custom color override for the node (CSS color string), or `null` for default. |
+| `nodeIcon` | `keyof LucideIcons \| null` | Icon to display on the node, from the [Lucide icon set](https://lucide.dev/icons/), or `null` for default. |
+| `nodeImage` | `string \| null` | URL to an image to display on the node (e.g., a profile picture), or `null`. |
+| `nodeFlag` | `flagColor \| null` | Color flag for visual tagging (see Flag colors below), or `null`. |
+| `nodeShape` | `NodeShape \| null` | Shape of the node (see Node shapes below), or `null` for default circle. |
+| `nodeMetadata` | `NodeMetadata` | Additional metadata as key-value pairs. Used for internal tracking and extended features. |
+| `x` | `number` | X coordinate position in the graph canvas. |
+| `y` | `number` | Y coordinate position in the graph canvas. |
+| `val` | `number` (optional) | Value used by the force-graph engine for sizing calculations. |
+| `neighbors` | `any[]` (optional) | Adjacent nodes, populated by the graph engine at runtime. |
+| `links` | `any[]` (optional) | Connected edges, populated by the graph engine at runtime. |
+
+### NodeProperties
+
+```typescript
+type NodeProperties = {
+  [key: string]: any
+}
+```
+
+A flexible key-value object containing all the entity's properties. When a Pydantic type is serialized into a node, its fields become entries in `nodeProperties`. For example, a `Domain` node would have `nodeProperties.domain`, `nodeProperties.root`, etc.
+
+### NodeMetadata
+
+```typescript
+type NodeMetadata = {
+  [key: string]: any
+}
+```
+
+Similar to `nodeProperties` but used for system-level metadata rather than entity data. This can include information like creation timestamps, source enricher, or other tracking data.
+
+### Node shapes
+
+Nodes can be rendered in four shapes:
+
+```typescript
+type NodeShape = 'circle' | 'square' | 'hexagon' | 'triangle'
+```
+
+| Shape | Use case |
+|-------|----------|
+| `circle` | Default shape for most entity types |
+| `square` | Often used for infrastructure entities |
+| `hexagon` | Used for grouped or aggregate entities |
+| `triangle` | Used for alert or warning-related entities |
+
+### Flag colors
+
+Flags provide a visual tagging system for nodes. Users can flag nodes to highlight them during an investigation.
+
+```typescript
+type flagColor = 'red' | 'orange' | 'blue' | 'green' | 'yellow'
+```
+
+Each flag color maps to specific Tailwind CSS classes for consistent styling:
+
+| Flag | Style |
+|------|-------|
+| `red` | `text-red-400 fill-red-200` |
+| `orange` | `text-orange-400 fill-orange-200` |
+| `blue` | `text-blue-400 fill-blue-200` |
+| `green` | `text-green-400 fill-green-200` |
+| `yellow` | `text-yellow-400 fill-yellow-200` |
+
+## GraphEdge
+
+A `GraphEdge` represents a relationship between two nodes (e.g., "RESOLVES_TO", "HAS_SUBDOMAIN", "FOUND_IN_BREACH"). Every relationship created by an enricher's `create_relationship()` method ends up as a `GraphEdge` in the frontend.
+
+```typescript
+type GraphEdge = {
+  source: GraphNode['id']
+  target: GraphNode['id']
+  date?: string
+  id: string
+  label: string
+  caption?: string
+  type?: string
+  weight?: number
+  confidence_level?: number | string
+}
+```
+
+### Field reference
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `source` | `string` | ID of the source node (the "from" node in the relationship). |
+| `target` | `string` | ID of the target node (the "to" node in the relationship). |
+| `id` | `string` | Unique identifier for this edge. |
+| `label` | `string` | Display label for the edge (e.g., `"RESOLVES_TO"`, `"HAS_EMAIL"`). This is the relationship type string passed to `create_relationship()`. |
+| `date` | `string` (optional) | Timestamp associated with the relationship (ISO 8601 format). |
+| `caption` | `string` (optional) | Additional descriptive text displayed on or near the edge. |
+| `type` | `string` (optional) | Relationship classification type for filtering or styling. |
+| `weight` | `number` (optional) | Numerical weight of the relationship, can be used for visual thickness or importance ranking. |
+| `confidence_level` | `number \| string` (optional) | Confidence score for the relationship, indicating how reliable the connection is. |
+
+## How enrichers map to the graph
+
+When you write an enricher and call graph methods in `postprocess()`, here's how the data flows:
+
+### Creating nodes
+
+```python
+# In your enricher's postprocess method
+self.create_node(domain)  # domain is a Pydantic Domain object
+```
+
+The graph service automatically:
+1. Extracts the **type name** from the Pydantic class (e.g., `"Domain"`) -> `nodeType`
+2. Reads the **primary field** value (e.g., `domain.domain`) -> used for `id`
+3. Reads the **nodeLabel** field (set by `compute_label()`) -> `nodeLabel`
+4. Serializes all fields into `nodeProperties`
+5. Sets defaults for visual properties (`nodeSize`, `nodeColor`, etc.)
+
+### Creating relationships
+
+```python
+# In your enricher's postprocess method
+self.create_relationship(domain, ip, "RESOLVES_TO")
+```
+
+The graph service automatically:
+1. Identifies the **source node** from `domain`'s type and primary field -> `source`
+2. Identifies the **target node** from `ip`'s type and primary field -> `target`
+3. Uses the relationship label `"RESOLVES_TO"` -> `label`
+4. Generates a unique `id` for the edge
+
+### Relationship naming conventions
+
+Relationship labels follow these conventions:
+- Use **UPPER_SNAKE_CASE** (e.g., `"RESOLVES_TO"`, `"HAS_SUBDOMAIN"`)
+- Use **verb phrases** that describe the relationship direction (source -> target)
+- The default relationship label is `"IS_RELATED_TO"` if none is specified
+- Common patterns:
+  - `HAS_*` for ownership/containment (e.g., `"HAS_EMAIL"`, `"HAS_SUBDOMAIN"`)
+  - `RESOLVES_TO` for DNS-type lookups
+  - `FOUND_IN_*` for breach/leak associations
+  - `REGISTERED_BY` for WHOIS data
+
+## Graph settings
+
+The graph visualization is configurable through settings types:
+
+### ForceGraphSetting
+
+Controls the physics simulation of the force-directed graph layout:
+
+```typescript
+type ForceGraphSetting = {
+  value: any
+  min?: number
+  max?: number
+  step?: number
+  type?: string
+  description?: string
+}
+```
+
+### ExtendedSetting
+
+General-purpose settings with richer type information:
+
+```typescript
+type ExtendedSetting = {
+  value: any
+  type: string
+  min?: number
+  max?: number
+  step?: number
+  options?: { value: string; label: string }[]
+  description?: string
+}
+```
+
+These settings allow users to customize graph behavior like node repulsion, link distance, simulation speed, and visual preferences.
+
+## Reserved properties
+
+When creating nodes, certain property names are reserved by the system and should not be used as field names in your custom types:
+
+- `id` - Node identifier
+- `x`, `y` - Position coordinates
+- `nodeLabel` - Display label (set by `compute_label()`)
+- `label` - Legacy label field
+- `nodeType`, `type` - Entity type identifiers
+- `nodeImage`, `nodeIcon`, `nodeColor`, `nodeSize` - Visual properties
+- `created_at` - Creation timestamp
+- `sketch_id` - Investigation sketch association

+ 715 - 0
docs/developers/managing-enrichers.mdx

@@ -0,0 +1,715 @@
+---
+title: "Managing enrichers"
+description: "Quick start guide to creating Enricher for your OSINT investigations."
+category: "Developers"
+order: 10
+author: "Flowsint Team"
+tags: ["tutorial", "developers", "creating-a-new-enricher"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## Understanding Enrichers
+
+Enrichers are the high-level business logic layer in Flowsint. While types define data structures and tools wrap external utilities, enrichers orchestrate the entire intelligence gathering workflow. An enricher takes input data of one type, processes it through various tools or APIs, validates and enriches the results, creates graph database nodes and relationships, and returns structured output.
+
+Every enricher in Flowsint follows a two-phase execution model. The scan phase contains the core enriching logic where tools are executed, APIs are called, and data is gathered. Then, the postprocessing phase creates Neo4j graph nodes and relationships while returning the processed results. Input validation and normalization happens automatically through Pydantic type validation.
+
+Enrichers differ from tools in several fundamental ways. Tools are low-level wrappers that return raw data without knowledge of the Flowsint ecosystem. Enrichers are high-level workflows that understand types, create graph nodes, handle parameters, and orchestrate multiple tools. When you want to add a new data source, you create a tool. When you want to add a new intelligence workflow, you create an enricher.
+
+## Enricher architecture
+
+The enricher system is built around an abstract base class that defines the interface and execution flow. Every enricher you create inherits from this base class and implements specific methods.
+
+### The Enricher base class
+
+The base class lives at `flowsint-core/src/flowsint_core/core/enricher_base.py` and provides the framework for all enrichers. Here's what a minimal enricher looks like:
+
+```python
+from typing import List
+from flowsint_core.core.enricher_base import Enricher
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_types import Domain, Ip
+
+@flowsint_enricher
+class MyEnricher(Enricher):
+    """Description of what this enricher does."""
+
+    # Define input and output types as base types (not lists)
+    InputType = Domain
+    OutputType = Ip
+
+    @classmethod
+    def name(cls) -> str:
+        """Unique identifier for this enricher."""
+        return "domain_to_ip"
+
+    @classmethod
+    def category(cls) -> str:
+        """Category this enricher belongs to."""
+        return "Domain"
+
+    @classmethod
+    def key(cls) -> str:
+        """Primary key field name for this enricher."""
+        return "domain"
+
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        """Core enriching logic."""
+        pass
+
+    def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
+        """Create graph nodes and relationships."""
+        pass
+
+# Export types for easy access
+InputType = MyEnricher.InputType
+OutputType = MyEnricher.OutputType
+```
+
+The `InputType` and `OutputType` class attributes define what data the enricher accepts and returns. These should be Pydantic types from the `flowsint-types` package defined as base types (e.g., `Domain`, not `List[Domain]`). The base class uses these type definitions to automatically generate JSON schemas for the API and handle validation automatically.
+
+At the end of the file, you should export the types for easy access by other modules.
+
+### The two phases
+
+Understanding the two execution phases is crucial for writing effective enrichers.
+
+**Scanning** is where the real work happens. This async method receives validated input data as a list of `InputType` instances and executes your intelligence gathering logic. You might instantiate tools, call external APIs, process results, and build up your output data. This phase should focus purely on gathering and processing data without worrying about the graph database. The base class automatically handles input validation through Pydantic before the scan phase begins.
+
+**Postprocessing** creates the graph database structure. After scanning completes, this method receives both the results and the original input. It creates Neo4j nodes for each entity, establishes relationships between them, and returns the final results. This separation keeps graph logic separate from business logic.
+
+## Creating a simple enricher
+
+Let's walk through creating a complete enricher from scratch. We'll build an enricher that converts domains to IP addresses using DNS resolution.
+
+### Setting up the file structure
+
+Enrichers are organized by their input type. Create a new file in the appropriate directory under `flowsint-enrichers/src/flowsint_enrichers/`:
+
+```bash
+cd flowsint-enrichers/src/flowsint_enrichers/domain/
+touch to_ip.py
+```
+
+If you're creating an enricher for a new input type, you may need to create a new directory first.
+
+### Implementing the basic structure
+
+Start with the imports and class definition:
+
+```python
+import socket
+from typing import List
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_core.core.enricher_base import Enricher
+from flowsint_core.core.logger import Logger
+from flowsint_types import Domain, Ip
+
+@flowsint_enricher
+class DomainToIpEnricher(Enricher):
+    """Resolves domain names to their IP addresses using DNS."""
+
+    # Define types as base types (not lists)
+    InputType = Domain
+    OutputType = Ip
+
+    @classmethod
+    def name(cls) -> str:
+        return "domain_to_ip"
+
+    @classmethod
+    def category(cls) -> str:
+        return "Domain"
+
+    @classmethod
+    def key(cls) -> str:
+        return "domain"
+
+    @classmethod
+    def documentation(cls) -> str:
+        return """
+        This enricher resolves domain names to their IP addresses using
+        standard DNS queries. It accepts a list of domains and returns
+        the corresponding IP addresses.
+        """
+
+# Export types at the end of the file
+InputType = DomainToIpEnricher.InputType
+OutputType = DomainToIpEnricher.OutputType
+```
+
+The `name()` method returns a unique identifier for this enricher. Use lowercase with underscores, following the pattern `inputtype_to_outputtype`. The `category()` groups related enrichers together in the UI. The `key()` specifies which field serves as the primary identifier, typically matching the input type.
+
+### Implementing the scan logic
+
+The scan method contains your core intelligence gathering logic. It receives a list of validated `InputType` instances and returns a list of `OutputType` instances.
+
+```python
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        """
+        Resolve each domain to its IP address.
+
+        Args:
+            data: List of Domain objects to resolve
+
+        Returns:
+            List of Ip objects
+        """
+        results: List[OutputType] = []
+
+        for domain in data:
+            try:
+                # Perform DNS resolution
+                ip_address = socket.gethostbyname(domain.domain)
+                # Create IP object
+                ip = Ip(address=ip_address)
+                results.append(ip)
+                # Log successful resolution
+                Logger.info(
+                    self.sketch_id,
+                    {"message": f"Resolved {domain.domain} to {ip_address}"}
+                )
+            except socket.gaierror as e:
+                # DNS resolution failed
+                Logger.info(
+                    self.sketch_id,
+                    {"message": f"Failed to resolve {domain.domain}: {e}"}
+                )
+                continue
+            except Exception as e:
+                # Unexpected error
+                Logger.error(
+                    self.sketch_id,
+                    {"message": f"Error resolving {domain.domain}: {e}"}
+                )
+                continue
+        return results
+```
+
+This implementation iterates through each domain, performs DNS resolution, creates an IP object for successful resolutions, and logs both successes and failures. The error handling ensures that failures don't crash the entire enricher, which is important when processing many domains.
+
+The input data has already been validated by Pydantic before reaching the scan method, so you can trust that all items are proper `Domain` objects.
+
+### Implementing postprocessing
+
+The postprocess method creates graph database nodes and relationships using the new simplified API:
+
+```python
+    def postprocess(self, results: List[OutputType], input_data: List[InputType] = None) -> List[OutputType]:
+        """
+        Create graph nodes and relationships.
+
+        Args:
+            results: IP objects from scan phase
+            input_data: Original Domain objects (preprocessed input)
+
+        Returns:
+            IP objects (unchanged)
+        """
+        # Create nodes and relationships
+        for domain, ip in zip(input_data, results):
+            # Create nodes by passing Pydantic objects directly
+            self.create_node(domain)
+            self.create_node(ip)
+
+            # Create relationship by passing Pydantic objects directly
+            self.create_relationship(domain, ip, "RESOLVES_TO")
+
+            # Log the operation
+            self.log_graph_message(
+                f"IP found for domain {domain.domain} -> {ip.address}"
+            )
+
+        return results
+```
+
+You can pass Pydantic objects directly to `create_node()` and `create_relationship()`. The methods automatically infer the node types, primary keys, and property values from the Pydantic models.
+
+The `create_node()` method accepts a Pydantic object and automatically creates a Neo4j node with the correct label and properties. The `create_relationship()` method takes two Pydantic objects and a relationship type string, inferring all necessary information from the objects.
+
+## Creating an enricher with tools
+
+Most enrichers use external tools for data gathering. Let's create an enricher that uses the Subfinder tool for subdomain enumeration.
+
+### Importing the tool
+
+Start by importing the tool along with your other dependencies:
+
+```python
+from typing import List
+from flowsint_core.core.enricher_base import Enricher
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_core.core.logger import Logger
+from flowsint_types import Domain
+from tools.network.subfinder import SubfinderTool
+
+@flowsint_enricher
+class SubdomainEnricher(Enricher):
+    """Enumerates subdomains for given domains using Subfinder."""
+
+    # Define types as base types
+    InputType = Domain
+    OutputType = Domain
+
+    @classmethod
+    def name(cls) -> str:
+        return "domain_to_subdomains"
+
+    @classmethod
+    def category(cls) -> str:
+        return "Domain"
+
+    @classmethod
+    def key(cls) -> str:
+        return "domain"
+
+# Export types
+InputType = SubdomainEnricher.InputType
+OutputType = SubdomainEnricher.OutputType
+```
+
+### Using the tool in scan
+
+The scan method instantiates and uses the tool:
+
+```python
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        """
+        Find subdomains using Subfinder tool.
+        Args:
+            data: List of Domain objects
+        Returns:
+            List of discovered subdomain Domain objects
+        """
+        results: List[OutputType] = []
+        # Instantiate the tool
+        subfinder = SubfinderTool()
+
+        for domain in data:
+            Logger.info(
+                self.sketch_id,
+                {"message": f"Enumerating subdomains for {domain.domain}"}
+            )
+            try:
+                # Launch the tool
+                subdomains = subfinder.launch(domain.domain)
+                # Convert strings to Domain objects
+                for subdomain in subdomains:
+                    results.append(Domain(domain=subdomain, root=False))
+                Logger.info(
+                    self.sketch_id,
+                    {"message": f"Found {len(subdomains)} subdomains for {domain.domain}"}
+                )
+            except Exception as e:
+                Logger.error(
+                    self.sketch_id,
+                    {"message": f"Error enumerating subdomains for {domain.domain}: {e}"}
+                )
+                continue
+        return results
+```
+
+Notice how the tool returns raw strings, and the enricher converts them into proper Domain objects. This separation of concerns keeps tools simple while enrichers handle type conversion.
+
+### Creating graph nodes and relationships
+
+The postprocess phase creates parent-child relationships between domains and subdomains:
+
+```python
+    def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
+        """
+        Create graph nodes and relationships for domains and subdomains.
+
+        Args:
+            results: Discovered subdomain Domain objects
+            input_data: Original parent Domain objects
+
+        Returns:
+            Subdomain Domain objects
+        """
+        # Create nodes for parent domains
+        for domain in input_data:
+            self.create_node(domain)
+
+        # Create nodes for subdomains and relationships
+        for subdomain in results:
+            self.create_node(subdomain)
+
+            # Extract parent domain name and create relationship
+            parent_domain_name = self._extract_parent_domain(subdomain.domain)
+            parent_domain = Domain(domain=parent_domain_name)
+
+            # Create relationship using Pydantic objects
+            self.create_relationship(parent_domain, subdomain, "HAS_SUBDOMAIN")
+
+            # Log the operation
+            self.log_graph_message(
+                f"Subdomain found: {parent_domain_name} -> {subdomain.domain}"
+            )
+
+        return results
+
+    def _extract_parent_domain(self, subdomain: str) -> str:
+        """Extract parent domain from subdomain."""
+        parts = subdomain.split('.')
+        if len(parts) >= 2:
+            return '.'.join(parts[-2:])
+        return subdomain
+```
+
+## Adding parameters to enrichers
+
+Many enrichers need user-configurable parameters. Let's create an enricher that scans ports with configurable options.
+
+### Defining the parameter schema
+
+The `get_params_schema()` class method defines what parameters your enricher accepts:
+
+```python
+from typing import List, Dict, Any, Optional
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_core.core.enricher_base import Enricher
+from flowsint_types import Ip, Port
+from tools.network.naabu import NaabuTool
+
+@flowsint_enricher
+class IpToPortsEnricher(Enricher):
+    """Scans IP addresses for open ports."""
+
+    # Define types as base types
+    InputType = Ip
+    OutputType = Port
+
+    @classmethod
+    def name(cls) -> str:
+        return "ip_to_ports"
+
+    @classmethod
+    def category(cls) -> str:
+        return "IP"
+
+    @classmethod
+    def key(cls) -> str:
+        return "address"
+
+    @classmethod
+    def get_params_schema(cls) -> List[Dict[str, Any]]:
+        """Define configurable parameters for this enricher."""
+        return [
+            {
+                "name": "mode",
+                "type": "select",
+                "description": "Scan mode: active scanning or passive enumeration",
+                "required": True,
+                "default": "passive",
+                "options": [
+                    {"label": "Passive", "value": "passive"},
+                    {"label": "Active", "value": "active"},
+                ],
+            },
+            {
+                "name": "port_range",
+                "type": "string",
+                "description": "Port range to scan (e.g., '1-1000' or '80,443,8080')",
+                "required": False,
+            },
+            {
+                "name": "top_ports",
+                "type": "select",
+                "description": "Scan only the most common ports",
+                "required": False,
+                "options": [
+                    {"label": "Top 100", "value": "100"},
+                    {"label": "Top 1000", "value": "1000"},
+                ],
+            },
+            {
+                "name": "PDCP_API_KEY",
+                "type": "vaultSecret",
+                "description": "ProjectDiscovery Cloud Platform API key for passive mode",
+                "required": False,
+            },
+        ]
+
+# Export types
+InputType = IpToPortsEnricher.InputType
+OutputType = IpToPortsEnricher.OutputType
+```
+
+The parameter schema defines the type, description, whether it's required, default values, and for select parameters, the available options. The `vaultSecret` type integrates with Flowsint's encrypted credential storage.
+
+### Using parameters in your enricher
+
+Parameters are accessed through `self.params` in your scan method:
+
+```python
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        """
+        Scan IPs for open ports using configured parameters.
+        Args:
+            data: List of Ip objects to scan
+        Returns:
+            List of Port objects
+        """
+        results: List[OutputType] = []
+        # Extract parameters
+        mode = self.params.get("mode", "passive")
+        port_range = self.params.get("port_range")
+        top_ports = self.params.get("top_ports")
+        api_key = self.get_secret("PDCP_API_KEY")
+
+        # Instantiate tool
+        naabu = NaabuTool()
+
+        for ip in data:
+            Logger.info(
+                self.sketch_id,
+                {"message": f"Scanning {ip.address} in {mode} mode"}
+            )
+            try:
+                # Launch tool with parameters
+                scan_results = naabu.launch(
+                    target=ip.address,
+                    mode=mode,
+                    port_range=port_range,
+                    top_ports=top_ports,
+                    api_key=api_key
+                )
+                # Convert tool results to Port objects
+                for result in scan_results:
+                    port = Port(
+                        number=result.get("port"),
+                        protocol=result.get("protocol", "tcp").upper(),
+                        state="open",
+                        service=result.get("service"),
+                        banner=result.get("version")
+                    )
+                    results.append(port)
+            except Exception as e:
+                Logger.error(
+                    self.sketch_id,
+                    {"message": f"Error scanning {ip.address}: {e}"}
+                )
+                continue
+        return results
+```
+
+## Handling multiple output types
+
+Some enrichers produce multiple types of results. You can define a custom return type using Pydantic:
+
+```python
+from pydantic import BaseModel
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_core.core.enricher_base import Enricher
+from typing import List
+from flowsint_types import Website, Email, Phone
+
+class CrawlerResults(BaseModel):
+    """Results from web crawler including multiple entity types."""
+    website: Website
+    emails: List[Email] = []
+    phones: List[Phone] = []
+
+@flowsint_enricher
+class WebsiteToCrawlerEnricher(Enricher):
+    """Crawls websites to extract emails and phone numbers."""
+
+    # Define types as base types
+    InputType = Website
+    OutputType = CrawlerResults
+
+    async def scan(self, data: List[InputType]) -> List[OutputType]:
+        """Crawl websites and extract contact information."""
+        from tools.network.reconcrawl import ReconCrawlTool
+
+        results: List[OutputType] = []
+        crawler_tool = ReconCrawlTool()
+
+        for website in data:
+            try:
+                # Launch crawler
+                crawl_data = crawler_tool.launch(website.url)
+
+                # Extract entities
+                emails = [Email(email=e) for e in crawl_data.get("emails", [])]
+                phones = [Phone(number=p) for p in crawl_data.get("phones", [])]
+
+                # Create result object
+                result = CrawlerResults(
+                    website=website,
+                    emails=emails,
+                    phones=phones
+                )
+                results.append(result)
+
+            except Exception as e:
+                Logger.error(self.sketch_id, {"message": f"Crawl error: {e}"})
+
+        return results
+
+    def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
+        """Create nodes for all discovered entities."""
+        for result in results:
+            # Create website node using Pydantic object
+            self.create_node(result.website)
+
+            # Create email nodes and relationships
+            for email in result.emails:
+                self.create_node(email)
+                self.create_relationship(result.website, email, "HAS_EMAIL")
+
+            # Create phone nodes and relationships
+            for phone in result.phones:
+                self.create_node(phone)
+                self.create_relationship(result.website, phone, "HAS_PHONE")
+
+            self.log_graph_message(
+                f"Processed {len(result.emails)} emails and {len(result.phones)} phones from {result.website.url}"
+            )
+
+        return results
+
+# Export types
+InputType = WebsiteToCrawlerEnricher.InputType
+OutputType = WebsiteToCrawlerEnricher.OutputType
+```
+
+## Registering your enricher
+
+You don't need to register your enricher anywhere, adding the decorator `@flowsint_enricher` to your enricher class triggers the auto discovery.
+
+```python
+from flowsint_enrichers.registry import flowsint_enricher
+from flowsint_core.core.enricher_base import Enricher
+
+@flowsint_enricher
+class MyEnricher(Enricher):
+...
+```
+
+## Testing your enricher
+
+Creating tests helps ensure your enricher works correctly and makes debugging easier. Create a test file in `flowsint-enrichers/tests/`:
+
+```python
+# tests/test_domain_to_ip.py
+import pytest
+from flowsint_enrichers.domain.to_ip import DomainToIpEnricher
+from flowsint_types import Domain, Ip
+
+@pytest.mark.asyncio
+async def test_enricher_metadata():
+    """Test enricher metadata is correctly defined."""
+    assert DomainToIpEnricher.name() == "domain_to_ip"
+    assert DomainToIpEnricher.category() == "Domain"
+    assert DomainToIpEnricher.key() == "domain"
+
+@pytest.mark.asyncio
+async def test_type_definitions():
+    """Test InputType and OutputType are correctly defined."""
+    assert DomainToIpEnricher.InputType == Domain
+    assert DomainToIpEnricher.OutputType == Ip
+
+@pytest.mark.asyncio
+async def test_scan():
+    """Test DNS resolution works."""
+    enricher = DomainToIpEnricher(sketch_id="test", scan_id="test")
+    input_data = [Domain(domain="example.com")]
+    results = await enricher.scan(input_data)
+
+    assert len(results) > 0
+    assert isinstance(results[0], Ip)
+    assert results[0].address  # Should have an IP address
+```
+
+These tests verify that your enricher's metadata is correct, type definitions are properly set, and the scan logic produces expected results. Input validation is handled automatically by Pydantic, so you don't need to test preprocessing separately.
+
+## Best practices
+
+When creating enrichers, think carefully about error handling. Intelligence gathering involves many external systems that can fail in unpredictable ways. Your enricher should handle errors gracefully, log failures clearly, and continue processing remaining items rather than crashing entirely.
+
+Always use the Logger utility for tracking progress and errors. The logger integrates with Flowsint's monitoring system and helps users understand what's happening during long-running enrichers. Log successful operations at the info level and errors at the error level.
+
+Define InputType and OutputType as base types (e.g., `Domain`, not `List[Domain]`). The base class automatically handles the list wrapping and validation. This makes the type definitions cleaner and more intuitive.
+
+Always export your types at the end of the file using:
+```python
+InputType = YourEnricher.InputType
+OutputType = YourEnricher.OutputType
+```
+
+Use the simplified graph API by passing Pydantic objects directly to `create_node()` and `create_relationship()`. This eliminates boilerplate code and reduces errors. The methods automatically infer node types, primary keys, and properties from your Pydantic models.
+
+Separate concerns between the two phases. Scanning should focus on gathering and processing data. Postprocessing should create graph structures. Input validation happens automatically through Pydantic, so you don't need to handle it manually.
+
+Use type hints everywhere. They provide automatic validation, better IDE support, and serve as inline documentation. The InputType and OutputType class attributes should always be Pydantic types from the flowsint-types package.
+
+When working with tools, remember that they return raw data structures. Your enricher is responsible for converting tool output into proper Flowsint types. This type conversion is typically done in the scan phase.
+
+Document your enricher thoroughly. The class docstring, documentation method, and parameter descriptions all appear in the UI. Clear documentation helps users understand what your enricher does and how to configure it.
+
+### Handling API Rate Limits
+
+When working with rate-limited APIs, add delays between requests:
+
+```python
+    async def scan(self, data: InputType) -> OutputType:
+        """Scan with rate limiting to respect API limits."""
+        import asyncio
+        results = []
+        delay_seconds = 1  # Delay between requests
+        for item in data:
+            result = await self._query_api(item)
+            if result:
+                results.append(result)
+            # Respect rate limits
+            await asyncio.sleep(delay_seconds)
+        return results
+```
+
+### Fallback data sources
+
+Implement fallback logic when primary sources fail:
+
+```python
+    async def scan(self, data: InputType) -> OutputType:
+        """Try multiple data sources with fallback logic."""
+        results = []
+
+        for domain in data:
+            # Try primary source
+            result = self._query_primary_source(domain)
+
+            if not result:
+                # Fall back to secondary source
+                Logger.info(
+                    self.sketch_id,
+                    {"message": f"Primary source failed for {domain}, trying fallback"}
+                )
+                result = self._query_fallback_source(domain)
+
+            if result:
+                results.append(result)
+
+        return results
+```
+
+## Troubleshooting
+
+If your enricher doesn't appear in the API, verify that you've applied the `@flowsint_enricher` decorator to your class, that the file lives under `flowsint-enrichers/src/flowsint_enrichers/`, and that you've restarted the API server. Auto-discovery via `load_all_enrichers()` runs at startup, so file additions require a restart.
+
+For import errors, make sure all your dependencies are installed and the enricher file has no syntax errors. Check that you're importing from the correct packages.
+
+If the scan method isn't finding any results, add logging statements to debug what's happening. Verify that tools are installed and accessible, API keys are valid if required, and input data is in the expected format.
+
+When graph relationships aren't appearing, check that you're creating both nodes and relationships in the postprocess method. Verify that the relationship type name is correct and that you're passing the right node objects to `create_relationship()`.
+
+## Next steps
+
+Once you've created and registered your enricher, it becomes available through the API for users to run. Enrichers can be chained together into Flows where the output of one enricher feeds into the input of another. This enables complex intelligence gathering sequences.
+
+Remember that enrichers are the heart of Flowsint's intelligence gathering capabilities. Well-designed enrichers that handle errors gracefully, log progress clearly, and create meaningful graph relationships make the entire platform more powerful and user-friendly.
+
+If you create new enrichers that you think can help the community, it's highly appreciated that you open-source them. Help the community as much as it helps you !

+ 512 - 0
docs/developers/managing-tools.mdx

@@ -0,0 +1,512 @@
+---
+title: "Managing tools"
+description: "This guide explains how to create a new tool in the Flowsint ecosystem. Tools are low-level wrappers around external utilities, Docker containers, and APIs that enrichers use to gather intelligence. Understanding the tool architecture will help you extend Flowsint's capabilities with new data sources and reconnaissance utilities."
+category: "Developers"
+order: 9
+author: "Flowsint Team"
+tags: ["tutorial", "developers", "creating-a-new-tool"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## Understanding tools
+
+Tools in Flowsint serve as abstraction layers between enrichers and external systems. They provide a consistent interface for executing Docker containers, calling APIs, or running python libraries. While enrichers handle high-level orchestration and graph database operations, tools focus exclusively on executing external commands and returning raw results.
+
+Every tool implements a basic interface with methods for naming, categorization, and execution. Tools don't know anything about Pydantic types, Neo4j graphs, or the broader Flowsint architecture. They just wrap external functionality and return data.
+
+Flowsint currently includes tools for subdomain enumeration, port scanning, DNS queries, WHOIS lookups, web crawling, and business intelligence.
+
+## Tool architecture
+
+The tool system has a two-tier inheritance structure. At the base level, you have the abstract `Tool` class that defines the interface every tool must implement. For tools that run in Docker containers, there's an intermediate `DockerTool` class that handles all the container lifecycle management.
+
+### The Tool base class
+
+Every tool inherits from the abstract `Tool` class, which lives at `flowsint-enrichers/src/tools/base.py`. Here's what it looks like:
+
+```python
+from abc import ABC, abstractmethod
+from typing import Any
+
+class Tool(ABC):
+    """Abstract base class for all tools."""
+
+    @classmethod
+    @abstractmethod
+    def name(cls) -> str:
+        """Return the tool name."""
+        pass
+
+    @classmethod
+    @abstractmethod
+    def category(cls) -> str:
+        """Return the tool category."""
+        pass
+
+    @classmethod
+    @abstractmethod
+    def description(cls) -> str:
+        """Return a description of what the tool does."""
+        pass
+
+    @classmethod
+    @abstractmethod
+    def version(cls) -> str:
+        """Return the tool version."""
+        pass
+
+    @abstractmethod
+    def launch(self, value: str, *args, **kwargs) -> Any:
+        """Execute the tool and return results."""
+        pass
+```
+
+Any tool you create must implement these five methods. The first four are class methods that provide metadata about the tool. The `launch` method is where the actual work happens.
+
+### The DockerTool class
+
+Most security and reconnaissance tools run in Docker containers for isolation and portability. The `DockerTool` class at `flowsint-enrichers/src/tools/dockertool.py` provides all the infrastructure for running containerized tools.
+
+When you inherit from `DockerTool`, you get automatic image management, container execution, volume mounting, environment variable handling, and cleanup. You just specify the Docker image name and implement how to construct the command.
+
+Here's a simplified view of what `DockerTool` provides:
+
+```python
+class DockerTool(Tool):
+    """Base class for tools that run in Docker containers."""
+
+    def __init__(self, image: str, default_tag: str = "latest"):
+        """Initialize with Docker image information."""
+        self.image = image
+        self.default_tag = default_tag
+        self.docker_client = docker.from_env()
+
+    def install(self) -> None:
+        """Pull the Docker image if not already present."""
+        # Pulls image from Docker Hub
+        pass
+
+    def is_installed(self) -> bool:
+        """Check if the Docker image exists locally."""
+        # Checks local images
+        pass
+
+    def launch(self, command: str, volumes: dict = None,
+               timeout: int = 30, environment: dict = None) -> Any:
+        """Run a command in the Docker container."""
+        # Executes container and returns output
+        pass
+```
+
+The `launch` method in `DockerTool` handles container execution. It sets up the environment, mounts volumes if needed, runs the container, captures output, and cleans up afterward.
+
+## Creating a simple API-based tool
+
+Let's start with the simpler case of creating a tool that calls an external API. We'll create a hypothetical tool for querying a threat intelligence service.
+
+### File structure
+
+Create a new python file in the appropriate category directory under `flowsint-enrichers/src/tools/`. For a security-related tool, you might use `tools/security/`:
+
+```bash
+cd flowsint-enrichers/src/tools/security/
+touch threat_intel.py
+```
+
+If the category directory doesn't exist, create it first and add an `__init__.py` file to make it a python package.
+
+### Basic implementation
+
+Here's a complete example of an API-based tool:
+
+```python
+from tools.base import Tool
+from typing import Any, Dict, List, Optional
+import requests
+
+class ThreatIntelTool(Tool):
+    """Query threat intelligence data from an external API."""
+
+    api_endpoint = "https://api.threatintel.example.com/v1"
+
+    @classmethod
+    def name(cls) -> str:
+        """Return the tool name."""
+        return "threatintel"
+
+    @classmethod
+    def category(cls) -> str:
+        """Return the category this tool belongs to."""
+        return "Threat Intelligence"
+
+    @classmethod
+    def description(cls) -> str:
+        """Return a description of what this tool does."""
+        return "Queries threat intelligence data for IPs, domains, and hashes"
+
+    @classmethod
+    def version(cls) -> str:
+        """Return the tool version."""
+        return "1.0.0"
+
+    def launch(
+        self,
+        indicator: str,
+        indicator_type: str = "ip",
+        api_key: Optional[str] = None
+    ) -> List[Dict[str, Any]]:
+        """
+        Query the threat intelligence API.
+
+        Args:
+            indicator: The indicator to query (IP, domain, hash, etc.)
+            indicator_type: Type of indicator (ip, domain, hash)
+            api_key: API key for authentication
+
+        Returns:
+            List of threat intelligence records
+        """
+        if not api_key:
+            raise ValueError("API key is required")
+
+        headers = {
+            "Authorization": f"Bearer {api_key}",
+            "Content-Type": "application/json"
+        }
+
+        params = {
+            "indicator": indicator,
+            "type": indicator_type
+        }
+
+        try:
+            response = requests.get(
+                f"{self.api_endpoint}/query",
+                headers=headers,
+                params=params,
+                timeout=30
+            )
+            response.raise_for_status()
+            return response.json().get("results", [])
+
+        except requests.exceptions.RequestException as e:
+            print(f"Error querying threat intel API: {e}")
+            return []
+```
+
+This tool follows a straightforward pattern. The class methods provide metadata that enrichers and the registry use. The `launch` method implements the actual API interaction, handling authentication, making the request, and returning structured data.
+
+Notice how the tool returns simple python data structures like lists and dictionaries. Tools don't know about Pydantic types or Flowsint models. That's the enricher's job.
+
+## Creating a docker-based tool
+
+Docker-based tools are more common in Flowsint because most reconnaissance utilities need specific dependencies and isolated environments. Let's walk through creating a tool that wraps a hypothetical Docker-based subdomain scanner.
+
+### Setting up the class
+
+Start by inheriting from `DockerTool` and providing the Docker image information:
+
+```python
+from tools.dockertool import DockerTool
+from typing import List, Optional, Any
+
+class MySubdomainTool(DockerTool):
+    """Wrapper for a Docker-based subdomain enumeration tool."""
+
+    image = "org/subdomain-scanner"
+    default_tag = "latest"
+
+    def __init__(self):
+        """Initialize the tool with Docker image information."""
+        super().__init__(self.image, self.default_tag)
+```
+
+The `image` and `default_tag` class attributes tell `DockerTool` which Docker image to use. When you instantiate the tool, it will automatically connect to the Docker daemon.
+
+### Implementing the launch method
+
+The `launch` method needs to construct the command that runs inside the container and handle the results:
+
+```python
+    def launch(
+        self,
+        domain: str,
+        timeout: int = 300,
+        wordlist: Optional[str] = None
+    ) -> List[str]:
+        """
+        Enumerate subdomains for a given domain.
+
+        Args:
+            domain: Target domain to enumerate
+            timeout: Maximum execution time in seconds
+            wordlist: Optional path to custom wordlist file
+
+        Returns:
+            List of discovered subdomain strings
+        """
+        # Ensure the Docker image is available
+        if not self.is_installed():
+            self.install()
+
+        # Build the command that runs inside the container
+        command = f"-d {domain}"
+
+        if wordlist:
+            command += f" -w {wordlist}"
+
+        # Add JSON output flag for easier parsing
+        command += " -json"
+
+        # Execute the container
+        try:
+            result = super().launch(
+                command=command,
+                timeout=timeout
+            )
+
+            # Parse the output
+            subdomains = self._parse_output(result)
+            return subdomains
+
+        except Exception as e:
+            print(f"Error running subdomain scanner: {e}")
+            return []
+
+    def _parse_output(self, output: str) -> List[str]:
+        """Parse the tool output and extract subdomains."""
+        import json
+
+        subdomains = []
+        for line in output.strip().split('\n'):
+            if not line:
+                continue
+            try:
+                data = json.loads(line)
+                if 'subdomain' in data:
+                    subdomains.append(data['subdomain'])
+            except json.JSONDecodeError:
+                continue
+
+        return list(set(subdomains))  # Remove duplicates
+```
+
+This implementation shows several important patterns. First, it checks if the Docker image is installed and pulls it if necessary. Second, it constructs the command string that will run inside the container. Third, it calls the parent class's `launch` method to handle the actual container execution. Finally, it parses the output into a clean python data structure.
+
+### Handling volumes
+
+Some tools need access to files on the host system. You can mount volumes when calling the parent's `launch` method:
+
+```python
+    def launch(self, domain: str, wordlist_path: str = None) -> List[str]:
+        """Run the tool with optional wordlist file."""
+
+        command = f"-d {domain}"
+        volumes = None
+
+        if wordlist_path:
+            # Mount the wordlist file into the container
+            volumes = {
+                wordlist_path: {
+                    'bind': '/wordlist.txt',
+                    'mode': 'ro'  # read-only
+                }
+            }
+            command += " -w /wordlist.txt"
+
+        result = super().launch(
+            command=command,
+            volumes=volumes
+        )
+
+        return self._parse_output(result)
+```
+
+The volumes dictionary maps host paths to container paths. You can specify the mount mode as 'ro' for read-only or 'rw' for read-write.
+
+### Using environment variables
+
+For tools that need API keys or configuration through environment variables:
+
+```python
+    def launch(self, domain: str, api_key: Optional[str] = None) -> List[str]:
+        """Run the tool with optional API key for enhanced scanning."""
+
+        command = f"-d {domain}"
+        environment = {}
+
+        if api_key:
+            environment['API_KEY'] = api_key
+
+        result = super().launch(
+            command=command,
+            environment=environment
+        )
+
+        return self._parse_output(result)
+```
+
+## Testing your tool
+
+Creating tests for your tool helps ensure it works correctly and makes it easier to catch regressions. Create a test file in `flowsint-enrichers/tests/tools/` that mirrors your tool's location:
+
+```python
+# tests/tools/security/test_threat_intel.py
+from tools.security.threat_intel import ThreatIntelTool
+import pytest
+
+def test_tool_metadata():
+    """Test that tool metadata is correctly defined."""
+    assert ThreatIntelTool.name() == "threatintel"
+    assert ThreatIntelTool.category() == "Threat Intelligence"
+    assert "threat" in ThreatIntelTool.description().lower()
+
+def test_tool_launch_requires_api_key():
+    """Test that launch method requires an API key."""
+    tool = ThreatIntelTool()
+    with pytest.raises(ValueError):
+        tool.launch("192.0.2.1")
+
+def test_tool_launch_with_api_key(monkeypatch):
+    """Test successful API query with mocked response."""
+    tool = ThreatIntelTool()
+
+    # Mock the requests.get call
+    def mock_get(*args, **kwargs):
+        class MockResponse:
+            def raise_for_status(self):
+                pass
+            def json(self):
+                return {"results": [{"indicator": "192.0.2.1", "threat_level": "high"}]}
+        return MockResponse()
+
+    monkeypatch.setattr("requests.get", mock_get)
+
+    results = tool.launch("192.0.2.1", api_key="test_key")
+    assert len(results) == 1
+    assert results[0]["indicator"] == "192.0.2.1"
+```
+
+For docker-based tools, your tests need Docker to be running:
+
+```python
+# tests/tools/network/test_my_subdomain_tool.py
+from tools.network.my_subdomain_tool import MySubdomainTool
+import pytest
+
+@pytest.mark.docker
+def test_tool_install():
+    """Test that the Docker image can be pulled."""
+    tool = MySubdomainTool()
+    tool.install()
+    assert tool.is_installed()
+
+@pytest.mark.docker
+def test_tool_launch():
+    """Test running the tool against a domain."""
+    tool = MySubdomainTool()
+    results = tool.launch("example.com")
+    assert isinstance(results, list)
+```
+
+The `@pytest.mark.docker` decorator helps you separate tests that require Docker from those that don't.
+
+## Best practices
+
+When creating tools, focus on simplicity and single responsibility. Each tool should wrap exactly one external utility or API. Don't try to combine multiple data sources in a single tool. That's what enrichers are for.
+
+Always handle errors gracefully. Network requests fail, Docker containers crash, and APIs return unexpected data. Your tool should catch these errors, log them appropriately, and return empty results or raise clear exceptions rather than crashing.
+
+Return simple data structures from the `launch` method. Use lists, dictionaries, strings, and numbers. Don't return Pydantic models or other complex objects. Remember that tools are low-level utilities that enrichers build upon.
+
+For Docker tools, always check if the image is installed before running it. The pattern of checking `is_installed()` and calling `install()` if necessary ensures the tool works even on fresh installations.
+
+When parsing tool output, be defensive. External tools can return unexpected formats, partial results, or garbage data. Validate and clean the output before returning it. Use try-except blocks around parsing logic.
+
+Document your tool thoroughly. The docstrings and parameter descriptions help other developers understand how to use your tool. Future enrichers will rely on this documentation.
+
+## Integrating your tool
+
+Unlike types, tools don't need to be explicitly registered in a central registry. Enrichers import and use them directly. When you create an enricher that uses your new tool, you simply import it:
+
+```python
+# In an enricher file
+from tools.security.threat_intel import ThreatIntelTool
+
+class IpToThreatIntelEnricher(Enricher):
+    async def scan(self, data: List[Ip]) -> List[ThreatReport]:
+        tool = ThreatIntelTool()
+        results = []
+
+        for ip in data:
+            intel = tool.launch(
+                indicator=ip.address,
+                indicator_type="ip",
+                api_key=api_key
+            )
+            # Process results...
+
+        return results
+```
+
+The enricher instantiates your tool, calls its `launch` method with appropriate parameters, and processes the results into Flowsint types.
+
+## Common patterns
+
+Several patterns appear frequently in Flowsint tools. Understanding these will help you write tools that fit naturally into the ecosystem.
+
+### The install-check pattern
+
+Most Docker tools follow this pattern at the start of `launch`:
+
+```python
+def launch(self, ...):
+    if not self.is_installed():
+        self.install()
+
+    # Continue with execution
+```
+
+This ensures the docker image is available before trying to run it.
+
+### The command builder pattern
+
+Complex tools often build commands incrementally based on parameters:
+
+```python
+def launch(self, target: str, mode: str = "fast", verbose: bool = False):
+    command = f"-target {target}"
+
+    if mode == "thorough":
+        command += " --thorough"
+
+    if verbose:
+        command += " -v"
+
+    result = super().launch(command)
+```
+
+### The output parser pattern
+
+Many tools separate execution from parsing:
+
+```python
+def launch(self, ...):
+    raw_output = super().launch(command)
+    return self._parse_output(raw_output)
+
+def _parse_output(self, output: str) -> List[Dict]:
+    """Parse raw tool output into structured data."""
+    # Parsing logic here
+```
+
+This separation makes the code easier to test and maintain.
+
+## Next steps
+
+Once you've created your tool and tested it, you can build enrichers that use it. Enrichers orchestrate one or more tools to gather intelligence, validate the results, convert them to Flowsint types, and create graph database nodes and relationships.
+
+If your tool requires API keys or other secrets, enrichers can access them through the vault system. When you implement an enricher that uses your tool, you can define parameters of type `vaultSecret` that pull credentials from the user's encrypted vault.
+
+Remember that tools are just one layer in the Flowsint architecture. They provide the raw capabilities, but enrichers provide the intelligence and graph-building logic that makes the platform powerful.

+ 1253 - 0
docs/developers/managing-types.mdx

@@ -0,0 +1,1253 @@
+---
+title: "Managing types"
+description: "This guide walks you through the process of creating a new data type in the Flowsint ecosystem and integrating it throughout the platform. Types in Flowsint serve as the foundation for all data modeling, providing structure, validation, and schema generation for the entire system."
+category: "Developers"
+order: 8
+author: "Flowsint Team"
+tags: ["tutorial", "developers", "creating-a-new-type"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## Understanding the type system
+
+The Flowsint type system is built on Pydantic models and lives in the `flowsint-types` package. Every type is a python class that inherits from `FlowsintType`, which itself inherits from `pydantic.BaseModel`, **and must be decorated with `@flowsint_type`** to be registered in the global type registry. This provides automatic validation, serialization, JSON schema generation, auto-discovery, and graph-specific functionality like automatic label generation. The architecture is deliberately simple with minimal inheritance hierarchies. Each type inherits from FlowsintType and defines its own fields and behavior.
+
+The package structure is straightforward. Inside `flowsint-types/src/flowsint_types/`, you'll find individual python files for each type. Most types get their own file, though closely related types sometimes share a file. For example, `wallet.py` contains `CryptoWallet`, `CryptoWalletTransaction`, and `CryptoNFT` because they work together as a conceptual unit.
+
+Currently, Flowsint includes 39 built-in types covering everything from network entities like domains and IPs to identity information like individuals and organizations, security data like credentials and breaches, and financial information like bank accounts and crypto wallets.
+
+### What is FlowsintType?
+
+`FlowsintType` is the base class for all Flowsint entity types. It extends Pydantic's `BaseModel` with additional functionality specific to Flowsint's graph database and UI needs:
+
+```python
+class FlowsintType(BaseModel):
+    """Base class for all Flowsint entity types with nodeLabel support.
+    nodeLabel is optional but computed at definition time.
+
+    All classes that inherit from FlowsintType must be decorated with @flowsint_type
+    to be registered in the global TYPE_REGISTRY and accessed by their class name.
+
+    Usage:
+        from flowsint_types.registry import flowsint_type
+
+        @flowsint_type
+        class Domain(FlowsintType):
+            domain: str
+    """
+
+    nodeLabel: Optional[str] = Field(
+        None,
+        description="UI-readable label for this entity, the one used on the graph.",
+        title="Label",
+    )
+
+    # Allow extra keys to support additional properties from user
+    class ConfigDict:
+        extra = "allow"
+```
+
+The `nodeLabel` field is automatically set by types using a `@model_validator` decorator, and this label is what appears on graph nodes in the Neo4j database and in the frontend UI. Every type should compute its own meaningful label based on its fields.
+
+The `ConfigDict` with `extra = "allow"` means types accept additional properties beyond their defined fields, which is useful for user-provided metadata.
+
+### The `@flowsint_type` decorator
+
+Every type **must** be decorated with `@flowsint_type` from `flowsint_types.registry`. This decorator registers the type in the global `TYPE_REGISTRY`, which enables:
+
+- Auto-discovery of all types at startup via `load_all_types()`
+- Lookup by class name (e.g., `TYPE_REGISTRY.get("Domain")`)
+- Lookup by lowercase name (e.g., `TYPE_REGISTRY.get_lowercase("domain")`) for Neo4j matching
+
+```python
+from flowsint_types.registry import flowsint_type
+from .flowsint_base import FlowsintType
+
+@flowsint_type  # Required for registration
+class MyType(FlowsintType):
+    ...
+```
+
+Without this decorator, your type will not be discoverable by the system.
+
+## Creating a new type
+
+Let's walk through the process of creating a new type from scratch. We'll use a hypothetical `Vehicle` type as our example.
+
+### Setting up the file
+
+Start by creating a new python file in the types directory. The filename should be lowercase and match your type name in snake_case. For a `Vehicle` type, you would create `vehicle.py`:
+
+```bash
+cd flowsint-types/src/flowsint_types/
+touch vehicle.py
+```
+
+### Basic structure
+
+Every type follows the same structural pattern. Here's what a basic type looks like:
+
+```python
+from pydantic import Field, model_validator
+from typing import Optional, Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class Vehicle(FlowsintType):
+    """Represents a vehicle with identifying information."""
+
+    license_plate: str = Field(
+        ...,
+        description="Vehicle license plate number",
+        title="License Plate",
+        json_schema_extra={"primary": True},
+    )
+    brand: Optional[str] = Field(
+        None,
+        description="Vehicle manufacturer such as Toyota or Ford",
+        title="Make"
+    )
+    model: Optional[str] = Field(
+        None,
+        description="Vehicle model name",
+        title="Model"
+    )
+    year: Optional[int] = Field(
+        None,
+        description="Year of manufacture",
+        title="Year"
+    )
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute a human-readable label for this vehicle."""
+        if self.brand and self.model and self.year:
+            self.nodeLabel = f"{self.license_plate} ({self.brand} {self.model} {self.year})"
+        else:
+            self.nodeLabel = self.license_plate
+        return self
+```
+
+Let's break down the key components:
+
+**Inheritance, imports, and decorator:**
+- The class inherits from `FlowsintType`
+- Import `FlowsintType` from `.flowsint_base`
+- Import `flowsint_type` from `.registry` and apply it as a decorator
+- Import `model_validator` and `Self` from Pydantic for the label computation
+
+**Docstring:**
+- Every type starts with a clear docstring explaining what it represents
+
+**Field definitions:**
+- Each field is defined as a class attribute with type hints
+- Use Pydantic's `Field()` function to provide metadata
+- Required fields use the ellipsis (`...`) as their default value
+- Optional fields use `Optional[Type]` in their type hint and `None` as the default value
+- Always provide `description` (for API docs) and `title` (for UI labels)
+
+**Primary field:**
+- The `json_schema_extra={"primary": True}` marks the unique identifier for this type
+- This field is used as the key when creating Neo4j nodes
+- **Critical:** Every type must have exactly one primary field
+- Choose a field that uniquely identifies instances of this type
+
+**Label computation:**
+- The `@model_validator(mode='after')` decorator runs after all field validation
+- The method must be named `compute_label` and return `self`
+- It sets `self.nodeLabel` to a human-readable string that will appear in the UI and graph
+- Handle cases where optional fields might be `None` to avoid ugly labels
+- The label should help users quickly identify what this entity is
+
+### Naming conventions
+
+Flowsint follows strict naming conventions to maintain consistency across the codebase. Class names use PascalCase (like `Vehicle`, `SocialAccount`, or `CryptoWallet`). Field names use snake_case (like `license_plate`, `phone_number`, or `email_address`). This matches python's standard conventions and makes the codebase more readable.
+
+### Understanding primary fields and labels
+
+Two concepts are crucial for every Flowsint type: the **primary field** and the **nodeLabel**. Understanding these will help you create types that work seamlessly with the graph database and UI.
+
+**Why it matters:**
+- When creating Neo4j nodes, this field is used as the key in `MERGE` operations
+- It ensures each entity is uniquely identified in the graph
+- The graph service extracts this field to determine node uniqueness
+
+**Rules for primary fields:**
+- Every type must have exactly one primary field
+- The primary field should uniquely identify instances
+- It's typically a required field (using `...` as default)
+- Common choices: IDs, usernames, emails, license plates, domain names
+
+**Examples of good primary fields:**
+- `Domain`: `domain` field (e.g., "example.com")
+- `Email`: `email` field (e.g., "user@example.com")
+- `Username`: `value` field (e.g., "john_doe")
+- `Ip`: `address` field (e.g., "192.168.1.1")
+- `SocialAccount`: `id` field (computed as "username@platform")
+
+#### The nodeLabel field and compute_label
+
+The `nodeLabel` is what users see in the UI and on graph nodes. It should be human-readable and help users quickly understand what an entity represents.
+
+**How it works:**
+1. `FlowsintType` provides a `nodeLabel` field (`Optional[str]`)
+2. Your type defines a `compute_label` method to set this field
+3. The method runs automatically after validation using `@model_validator(mode='after')`
+
+**Basic pattern:**
+
+```python
+from pydantic import model_validator
+from typing import Self
+
+@model_validator(mode='after')
+def compute_label(self) -> Self:
+    """Compute a human-readable label."""
+    self.nodeLabel = f"@{self.value}"
+    return self
+```
+
+**Advanced patterns:**
+
+When you have optional fields, handle `None` values gracefully:
+
+```python
+@model_validator(mode='after')
+def compute_label(self) -> Self:
+    """Compute label with optional display name."""
+    if self.display_name:
+        self.nodeLabel = f"{self.display_name} (@{self.username.value})"
+    else:
+        self.nodeLabel = f"@{self.username.value}"
+    return self
+```
+
+For types with multiple identifiers, you might compute a composite ID:
+
+```python
+@model_validator(mode='after')
+def compute_label_and_id(self) -> Self:
+    """Compute both ID and label."""
+    # Compute unique ID from username and platform
+    if self.username and self.platform:
+        self.id = f"{self.username.value}@{self.platform}"
+    elif self.username:
+        self.id = self.username.value
+
+    # Compute display label
+    if self.display_name:
+        self.nodeLabel = f"{self.display_name} (@{self.username.value})"
+    else:
+        self.nodeLabel = f"@{self.username.value}"
+    return self
+```
+
+**Best practices for labels:**
+- Keep labels concise but informative
+- Include the most identifying information first
+- Handle `None` values for optional fields
+- Use parentheses or separators to structure complex labels
+- Think about what users need to see at a glance on the graph
+
+**Real-world examples**
+
+```python
+# Simple: just the value
+# Username: "@john_doe"
+self.nodeLabel = f"@{self.value}"
+
+# With context: show platform if available
+# Username: "@john_doe (twitter)"
+if self.platform:
+    self.nodeLabel = f"@{self.value} ({self.platform})"
+else:
+    self.nodeLabel = f"@{self.value}"
+
+# Rich: combine multiple fields
+# Individual: "John Doe (john@example.com)"
+if self.email:
+    self.nodeLabel = f"{self.full_name} ({self.email})"
+else:
+    self.nodeLabel = self.full_name
+
+# Complex: show key information
+# Breach: "LinkedIn (2021) - 700M records"
+self.nodeLabel = f"{self.title} ({self.breachdate.split('-')[0]}) - {self.pwncount:,} records"
+```
+
+### Working with different field types
+
+Pydantic supports a wide range of field types beyond simple strings and integers. Here are the most common ones you'll use:
+
+```python
+from pydantic import Field, HttpUrl, model_validator
+from typing import Optional, List, Dict, Any, Self
+from datetime import datetime
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class ExampleType(FlowsintType):
+    """Demonstrates various field types."""
+
+    # Primary identifier
+    id: str = Field(
+        ...,
+        description="Unique identifier",
+        title="ID",
+        json_schema_extra={"primary": True}
+    )
+
+    # Primitive types
+    text_field: str = Field(..., description="A text string", title="Text")
+    number_field: int = Field(..., description="An integer number", title="Number")
+    decimal_field: float = Field(..., description="A decimal number", title="Decimal")
+    boolean_field: bool = Field(..., description="True or false value", title="Boolean")
+
+    # Optional fields
+    optional_text: Optional[str] = Field(None, description="Optional text", title="Optional Text")
+
+    # Collections - note the use of default_factory
+    tags: List[str] = Field(
+        default_factory=list,
+        description="List of tag strings",
+        title="Tags"
+    )
+
+    metadata: Dict[str, Any] = Field(
+        default_factory=dict,
+        description="Arbitrary metadata dictionary",
+        title="Metadata"
+    )
+
+    # Special Pydantic types
+    website: HttpUrl = Field(..., description="A validated URL", title="Website")
+    timestamp: datetime = Field(..., description="Date and time", title="Timestamp")
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this example."""
+        self.nodeLabel = f"{self.id} - {self.text_field}"
+        return self
+```
+
+When working with mutable types like lists and dictionaries, always use `default_factory` instead of providing a default value directly. Using `default_factory=list` is correct, while using `default=[]` would cause all instances to share the same list object, leading to subtle bugs.
+
+### Adding validation
+
+Sometimes you need more sophisticated validation than just type checking. Pydantic lets you add custom validators using the `field_validator` decorator:
+
+```python
+from pydantic import Field, field_validator
+from typing import Optional, Any, Self
+import ipaddress
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class Ip(FlowsintType):
+    """Represents an IP address with geolocation and ISP information."""
+
+    address: str = Field(
+        ...,
+        description="IP address",
+        title="IP Address",
+        json_schema_extra={"primary": True},
+    )
+   ...
+    @field_validator("address")
+    @classmethod
+    def validate_ip_address(cls, v: str) -> str:
+        """Validate that the address is a valid IP address."""
+        try:
+            ipaddress.ip_address(v)
+            return v
+        except ValueError:
+            raise ValueError(f"Invalid IP address: {v}")
+```
+
+Validators receive the field value and can either return a (potentially modified) value or raise a `ValueError` with an error message. Note that `@field_validator` runs before `@model_validator`, so the field is validated and normalized before the label is computed.
+
+### Referencing other types
+
+Types often need to reference other Flowsint types. You can import and use them just like any other python type:
+
+```python
+from pydantic import Field, model_validator
+from typing import Optional, Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+from .email import Email
+from .phone import Phone
+
+@flowsint_type
+class Contact(FlowsintType):
+    """Represents contact information for a person."""
+
+    name: str = Field(
+        ...,
+        description="Contact name",
+        title="Name",
+        json_schema_extra={"primary": True}
+    )
+    email: Optional[Email] = Field(None, description="Email address", title="Email")
+    phone: Optional[Phone] = Field(None, description="Phone number", title="Phone")
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this contact."""
+        self.nodeLabel = self.name
+        return self
+```
+
+For types with circular references or complex relationships, you may need to call `model_rebuild()` at the end of your file:
+
+```python
+from pydantic import Field, model_validator
+from typing import Optional, Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class CryptoWallet(FlowsintType):
+    """Represents a cryptocurrency wallet."""
+
+    address: str = Field(
+        ...,
+        description="Wallet address",
+        title="Address",
+        json_schema_extra={"primary": True}
+    )
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this wallet."""
+        self.nodeLabel = self.address
+        return self
+
+@flowsint_type
+class CryptoWalletTransaction(FlowsintType):
+    """Represents a transaction between wallets."""
+
+    transaction_id: str = Field(
+        ...,
+        description="Unique transaction ID",
+        title="Transaction ID",
+        json_schema_extra={"primary": True}
+    )
+    source: CryptoWallet = Field(..., description="Source wallet", title="Source")
+    target: Optional[CryptoWallet] = Field(None, description="Target wallet", title="Target")
+    amount: float = Field(..., description="Transaction amount", title="Amount")
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this transaction."""
+        self.nodeLabel = f"{self.amount} ({self.transaction_id[:8]}...)"
+        return self
+
+# Rebuild models to resolve forward references
+CryptoWallet.model_rebuild()
+CryptoWalletTransaction.model_rebuild()
+```
+
+## Exporting your type
+
+Once you've created your type, the `@flowsint_type` decorator handles registration automatically. However, you also need to export it from the package for convenient imports.
+
+### Updating the package exports
+
+Open `flowsint-types/src/flowsint_types/__init__.py` and add two things. First, import your new type at the top of the file with the other imports:
+
+```python
+from .address import Location
+from .affiliation import Affiliation
+from .alias import Alias
+# ... other imports ...
+from .vehicle import Vehicle  # Add your import here
+```
+
+Second, add your type name to the `__all__` list:
+
+```python
+__all__ = [
+    "Location",
+    "Affiliation",
+    "Alias",
+    # ... other types ...
+    "Vehicle",  # Add your type here
+]
+```
+
+The `__all__` list explicitly defines what gets exported when someone does `from flowsint_types import *`. While wildcard imports aren't always recommended, this ensures your type is properly exposed by the package.
+
+Note that the `@flowsint_type` decorator already registers your type in the `TYPE_REGISTRY` automatically when the module is imported, so the explicit import in `__init__.py` ensures it gets loaded at startup alongside all other types.
+
+### Installing the package
+
+After making these changes, you need to reinstall the package for them to take effect:
+
+```bash
+make prod
+#or
+cd flowsint-types
+poetry install
+```
+
+This updates the package in your development environment so enrichers and the API can import your new type.
+
+## Integrating with the API
+
+The final step is making your type available through the API so frontends can discover it and create instances.
+
+### Categorizing your type
+
+The API organizes types into logical categories that appear in the frontend. In the `TypeRegistryService._get_category_definitions()` method (located in `flowsint-core/src/flowsint_core/core/services/type_registry_service.py`), you'll find a list of category dictionaries. You need to add your type to an appropriate category or create a new one.
+
+Each category's `children` list contains tuples of `(TypeName, label_key, icon)`:
+- **TypeName**: The PascalCase class name of your type (e.g., `"Vehicle"`)
+- **label_key**: The field name used as the display key (e.g., `"license_plate"`)
+- **icon**: Optional icon override, or `None` to use the lowercase type name as icon
+
+You can either add to an existing category or create a new one.
+
+```python
+def _get_category_definitions(self) -> List[Dict[str, Any]]:
+    """Get the category definitions for types."""
+    return [
+        {
+            "id": uuid4(),
+            "type": "global",
+            "key": "global_category",
+            "icon": "phrase",
+            "label": "Global",
+            "fields": [],
+            "children": [
+                ("Phrase", "text", None),
+                ("Location", "address", None),
+            ],
+        },
+        {
+            "id": uuid4(),
+            "type": "person",
+            "key": "person_category",
+            "icon": "individual",
+            "label": "Identities & Entities",
+            "fields": [],
+            "children": [
+                ("Individual", "full_name", None),
+                ("Username", "value", "username"),
+                ("Organization", "name", None),
+            ],
+        },
+    ...
+```
+
+
+### Available categories
+
+Flowsint currently organizes types into these standard categories:
+
+- **Global** contains general-purpose types like Location and Phrase that don't fit neatly into other categories.
+
+- **Identities & Entities** includes Individual, Username, and Organization for representing people and groups.
+
+- **Organization** contains Organization for dedicated organizational lookups.
+
+- **Communication & Contact** covers Phone, Email, Username, SocialAccount, and Message for communication-related data.
+
+- **Network** encompasses all network-related types including ASN, CIDR, Domain, Website, Ip, Port, DNSRecord, SSLCertificate, and WebTracker.
+
+- **Security & Access** groups security-relevant types like Credential, Session, Device, Malware, and Weapon.
+
+- **Files & Documents** contains Document and File for representing digital files.
+
+- **Financial Data** includes BankAccount and CreditCard for financial information.
+
+- **Leaks** covers data breach information with the Leak type.
+
+- **Crypto** contains cryptocurrency-related types including CryptoWallet, CryptoWalletTransaction, and CryptoNFT.
+
+You can add your type to any of these categories or create a new category if none fit.
+
+<Alert variant="info">
+    <AlertTitle>Registered but uncategorized types</AlertTitle>
+    <AlertDescription>
+    Some types are registered (via `@flowsint_type`) and used as enricher inputs or outputs, but are intentionally not placed in any built-in category: `Affiliation`, `Alias`, `Breach`, `Gravatar`, `ReputationScore`, `RiskProfile`, `Script`, and `Whois`. They show up in the graph as nodes produced by enrichers (e.g. `Whois` is produced by `domain_to_whois`) but they don't appear in the type picker until you add them to `_get_category_definitions()`.
+    </AlertDescription>
+</Alert>
+
+## Complete examples
+
+Let' see some complete, real-world examples to illustrate different patterns.
+
+### Simple type example
+
+The simplest types have just one or two required fields and minimal complexity:
+
+```python
+from pydantic import Field, model_validator
+from typing import Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class Hashtag(FlowsintType):
+    """Represents a social media hashtag."""
+
+    tag: str = Field(
+        ...,
+        description="Hashtag text without the # symbol",
+        title="Hashtag",
+        json_schema_extra={"primary": True}
+    )
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this hashtag."""
+        self.nodeLabel = f"#{self.tag}"
+        return self
+```
+
+### Type with validation
+
+This example shows a Social Security Number type with format validation:
+
+```python
+from pydantic import Field, field_validator, model_validator
+from typing import Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+import re
+
+@flowsint_type
+class SocialSecurityNumber(FlowsintType):
+    """Represents a US Social Security Number."""
+
+    ssn: str = Field(
+        ...,
+        description="Social Security Number in format XXX-XX-XXXX",
+        title="SSN",
+        json_schema_extra={"primary": True}
+    )
+
+    @field_validator('ssn')
+    @classmethod
+    def validate_ssn_format(cls, v: str) -> str:
+        """Validate SSN format and normalize to standard format."""
+        clean = v.replace("-", "").replace(" ", "")
+
+        if not re.match(r"^\d{9}$", clean):
+            raise ValueError(
+                "SSN must be exactly 9 digits (format: XXX-XX-XXXX or XXXXXXXXX)"
+            )
+
+        return f"{clean[:3]}-{clean[3:5]}-{clean[5:]}"
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this SSN."""
+        # Mask most digits for privacy
+        self.nodeLabel = f"SSN ***-**-{self.ssn[-4:]}"
+        return self
+```
+
+### Type with related types
+
+This example shows how types can reference other types to build rich data models:
+
+```python
+from pydantic import Field, model_validator
+from typing import Optional, Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+from .email import Email
+from .domain import Domain
+
+@flowsint_type
+class Whois(FlowsintType):
+    """Represents WHOIS domain registration information."""
+
+    domain: Domain = Field(
+        ...,
+        description="Domain",
+        title="Domain",
+    )
+
+    registrar: Optional[str] = Field(
+        None,
+        description="Name of the domain registrar",
+        title="Registrar"
+    )
+
+    email: Optional[Email] = Field(
+        None,
+        description="Contact email address from WHOIS record",
+        title="Contact Email"
+    )
+
+    creation_date: Optional[str] = Field(
+        None,
+        description="Date when the domain was first registered",
+        title="Creation Date"
+    )
+
+    expiration_date: Optional[str] = Field(
+        None,
+        description="Date when the domain registration expires",
+        title="Expiration Date"
+    )
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this WHOIS record."""
+        if self.registrar:
+            self.nodeLabel = f"{self.domain.domain} (via {self.registrar})"
+        else:
+            self.nodeLabel = f"WHOIS: {self.domain.domain}"
+        return self
+```
+
+### Complex type with collections
+
+This example demonstrates a type with lists of other types and rich metadata:
+
+```python
+from pydantic import Field, model_validator
+from typing import Optional, List, Dict, Any, Self
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+from .individual import Individual
+from .address import Location
+
+@flowsint_type
+class Organization(FlowsintType):
+    """Represents an organization with comprehensive business information."""
+
+    name: str = Field(
+        ...,
+        description="Legal name of the organization",
+        title="Organization Name",
+        json_schema_extra={"primary": True}
+    )
+
+    registration_number: Optional[str] = Field(
+        None,
+        description="Official business registration number",
+        title="Registration Number"
+    )
+
+    headquarters: Optional[Location] = Field(
+        None,
+        description="Primary headquarters location",
+        title="Headquarters"
+    )
+
+    executives: List[Individual] = Field(
+        default_factory=list,
+        description="List of company executives and board members",
+        title="Executives"
+    )
+
+    locations: List[Location] = Field(
+        default_factory=list,
+        description="All office and facility locations",
+        title="Locations"
+    )
+
+    employee_count: Optional[int] = Field(
+        None,
+        description="Total number of employees",
+        title="Employee Count"
+    )
+
+    revenue: Optional[float] = Field(
+        None,
+        description="Annual revenue in USD",
+        title="Revenue"
+    )
+
+    industry: Optional[str] = Field(
+        None,
+        description="Primary industry sector",
+        title="Industry"
+    )
+
+    metadata: Dict[str, Any] = Field(
+        default_factory=dict,
+        description="Additional metadata and custom fields",
+        title="Metadata"
+    )
+
+    @model_validator(mode='after')
+    def compute_label(self) -> Self:
+        """Compute label for this organization."""
+        if self.industry:
+            self.nodeLabel = f"{self.name} ({self.industry})"
+        else:
+            self.nodeLabel = self.name
+        return self
+```
+
+## Best practices and common patterns
+
+### Documentation
+
+Keep documentation at the forefront. Every type should have:
+- A clear docstring explaining what it represents
+- A descriptive `description` parameter for each field (for API docs)
+- A meaningful `title` parameter for each field (for UI labels)
+
+Future developers (including yourself) will thank you for this clarity.
+
+### Required vs optional fields
+
+Think carefully about what should be required versus optional:
+- **Required fields** (using `...`): Only fields that uniquely identify an entity or are absolutely essential
+- **Optional fields** (using `Optional[Type]` and `None`): Most other fields should be optional since intelligence gathering is incremental and you rarely have complete information upfront
+
+### Always inherit from FlowsintType and use the decorator
+
+Never inherit directly from Pydantic's `BaseModel`. Always use `FlowsintType` and the `@flowsint_type` decorator:
+
+```python
+# Correct
+from .flowsint_base import FlowsintType
+from .registry import flowsint_type
+
+@flowsint_type
+class MyType(FlowsintType):
+    ...
+
+# Wrong - missing decorator
+from .flowsint_base import FlowsintType
+
+class MyType(FlowsintType):  # Not registered!
+    ...
+
+# Wrong - wrong base class
+from pydantic import BaseModel
+
+class MyType(BaseModel):  # Missing FlowsintType features
+    ...
+```
+
+### Always implement compute_label
+
+Every type must implement a `compute_label` method to set the `nodeLabel` displayed in the UI and graph:
+
+```python
+@model_validator(mode='after')
+def compute_label(self) -> Self:
+    """Compute a human-readable label."""
+    # Handle None values gracefully
+    if self.optional_field:
+        self.nodeLabel = f"{self.primary_field} ({self.optional_field})"
+    else:
+        self.nodeLabel = self.primary_field
+    return self
+```
+
+**Best practices for labels:**
+- Keep them concise but informative
+- Handle None values for optional fields gracefully
+- Put the most important information first
+- Think about what users need to see at a glance on the graph
+
+### Type hints and validation
+
+Use type hints everywhere. They provide:
+- Automatic validation
+- Better IDE support and autocomplete
+- Inline documentation
+- Runtime type checking via Pydantic
+
+For mutable default values like lists and dictionaries, always use `default_factory`:
+
+```python
+# Correct
+tags: List[str] = Field(default_factory=list)
+metadata: Dict[str, Any] = Field(default_factory=dict)
+
+# Wrong - all instances will share the same object!
+tags: List[str] = Field(default=[])
+metadata: Dict[str, Any] = Field(default={})
+```
+
+### Importing other types
+
+When referencing other Flowsint types, use relative imports to avoid circular import issues:
+
+```python
+# Correct
+from .email import Email
+from .phone import Phone
+
+# Avoid
+from flowsint_types import Email, Phone  # Can cause circular imports
+```
+
+If you encounter circular import problems, you can use forward references (strings) in type hints and call `model_rebuild()` at the end of your module.
+
+### Custom validation
+
+Consider adding custom validators for complex validation logic that goes beyond simple type checking:
+
+```python
+@field_validator('email')
+@classmethod
+def validate_email(cls, v: str) -> str:
+    """Validate and normalize email format."""
+    if not is_valid_email(v):
+        raise ValueError("Invalid email format")
+    return v.lower()
+```
+
+This keeps validation logic close to the type definition and ensures data integrity throughout the system.
+
+### Order of execution
+
+Remember the order in which Pydantic processes your type:
+1. **Field validators** (`@field_validator`) run first, validating and potentially transforming individual fields
+2. **Model validators** (`@model_validator`) run after, operating on the entire validated model
+3. Your `compute_label` method (a model validator) runs last, after all fields are validated
+
+This means you can safely access validated field values in `compute_label`.
+
+## Testing your type
+
+Writing tests for your types ensures they work correctly and helps catch bugs early. Create a test file in `flowsint-types/tests/` that matches your type filename.
+
+### Basic test structure
+
+```python
+# flowsint_types/tests/test_vehicle.py
+from flowsint_types import Vehicle
+import pytest
+
+def test_vehicle_creation():
+    """Test creating a vehicle with required fields."""
+    vehicle = Vehicle(license_plate="ABC123")
+    assert vehicle.license_plate == "ABC123"
+
+def test_vehicle_with_optional_fields():
+    """Test creating a vehicle with optional fields."""
+    vehicle = Vehicle(
+        license_plate="ABC123",
+        brand="Toyota",
+        model="Camry",
+        year=2020
+    )
+    assert vehicle.brand == "Toyota"
+    assert vehicle.year == 2020
+
+def test_vehicle_missing_required_field():
+    """Test that validation fails without required fields."""
+    with pytest.raises(ValueError):
+        Vehicle()  # Should fail - missing required field
+```
+
+### Testing label computation
+
+The label is crucial for UI display, so test it thoroughly:
+
+```python
+def test_vehicle_label_basic():
+    """Test label computation with only required fields."""
+    vehicle = Vehicle(license_plate="ABC123")
+    assert vehicle.nodeLabel == "ABC123"
+
+def test_vehicle_label_with_details():
+    """Test label computation with optional fields."""
+    vehicle = Vehicle(
+        license_plate="ABC123",
+        brand="Toyota",
+        model="Camry",
+        year=2020
+    )
+    assert vehicle.nodeLabel == "ABC123 (Toyota Camry 2020)"
+
+def test_vehicle_label_partial_details():
+    """Test label computation with some optional fields."""
+    vehicle = Vehicle(
+        license_plate="ABC123",
+        brand="Toyota"
+    )
+    # Should handle None values gracefully
+    assert vehicle.nodeLabel == "ABC123"
+```
+
+### Testing field validators
+
+If your type has custom validators, test both valid and invalid inputs:
+
+```python
+# tests/test_username.py
+from flowsint_types import Username
+import pytest
+
+def test_username_valid():
+    """Test valid username creation."""
+    username = Username(value="john_doe")
+    assert username.value == "john_doe"
+    assert username.nodeLabel == "john_doe"
+
+def test_username_validation_too_short():
+    """Test that usernames under 3 characters are rejected."""
+    with pytest.raises(ValueError, match="Must be 3-80 characters"):
+        Username(value="ab")
+
+def test_username_validation_invalid_chars():
+    """Test that invalid characters are rejected."""
+    with pytest.raises(ValueError, match="only letters, numbers, underscores, and hyphens"):
+        Username(value="john@doe")
+
+def test_username_validation_boundaries():
+    """Test boundary conditions."""
+    # Minimum length
+    username = Username(value="abc")
+    assert username.value == "abc"
+
+    # Maximum length
+    long_name = "a" * 80
+    username = Username(value=long_name)
+    assert username.value == long_name
+
+    # Too long
+    with pytest.raises(ValueError):
+        Username(value="a" * 81)
+```
+
+### Testing types with nested objects
+
+When your type contains other Flowsint types, test the relationships:
+
+```python
+# tests/test_social_account.py
+from flowsint_types import SocialAccount, Username
+import pytest
+
+def test_social_account_creation():
+    """Test creating a social account with a username object."""
+    username = Username(value="john_doe")
+    account = SocialAccount(
+        username=username,
+        platform="twitter",
+        profile_url="https://twitter.com/john_doe"
+    )
+
+    assert account.username.value == "john_doe"
+    assert account.platform == "twitter"
+    assert account.id == "john_doe@twitter"
+
+def test_social_account_label_with_display_name():
+    """Test label computation with display name."""
+    username = Username(value="john_doe")
+    account = SocialAccount(
+        username=username,
+        platform="twitter",
+        display_name="John Doe"
+    )
+
+    assert account.nodeLabel == "John Doe (@john_doe)"
+
+def test_social_account_label_without_display_name():
+    """Test label computation without display name."""
+    username = Username(value="john_doe")
+    account = SocialAccount(
+        username=username,
+        platform="twitter"
+    )
+
+    assert account.nodeLabel == "@john_doe"
+```
+
+### Testing serialization
+
+Verify that your types serialize correctly to JSON:
+
+```python
+def test_vehicle_serialization():
+    """Test that vehicle serializes to JSON correctly."""
+    vehicle = Vehicle(
+        license_plate="ABC123",
+        brand="Toyota",
+        model="Camry",
+        year=2020
+    )
+
+    # Convert to dict
+    data = vehicle.model_dump()
+    assert data["license_plate"] == "ABC123"
+    assert data["brand"] == "Toyota"
+    assert data["nodeLabel"] == "ABC123 (Toyota Camry 2020)"
+
+    # Convert to JSON string
+    json_str = vehicle.model_dump_json()
+    assert "ABC123" in json_str
+
+def test_vehicle_deserialization():
+    """Test creating vehicle from dictionary."""
+    data = {
+        "license_plate": "ABC123",
+        "brand": "Toyota",
+        "model": "Camry",
+        "year": 2020
+    }
+
+    vehicle = Vehicle(**data)
+    assert vehicle.license_plate == "ABC123"
+    assert vehicle.nodeLabel == "ABC123 (Toyota Camry 2020)"
+```
+
+### Running the tests
+
+To run your tests:
+
+```bash
+cd flowsint-types
+poetry run pytest tests/test_vehicle.py -v
+
+# Run all tests
+poetry run pytest -v
+
+# Run with coverage
+poetry run pytest --cov=flowsint_types tests/
+```
+
+### Best practices for testing
+
+- **Test the happy path first**: Basic creation with valid data
+- **Test validation**: Both valid and invalid inputs
+- **Test edge cases**: Empty strings, very long strings, boundary values
+- **Test label computation**: With and without optional fields
+- **Test serialization**: To/from dict and JSON
+- **Use descriptive test names**: The test name should describe what it tests
+- **Use pytest fixtures** for complex setup that's reused across tests
+
+Example with fixtures:
+
+```python
+import pytest
+from flowsint_types import Username, SocialAccount
+
+@pytest.fixture
+def sample_username():
+    """Fixture providing a sample username."""
+    return Username(value="john_doe")
+
+@pytest.fixture
+def sample_account(sample_username):
+    """Fixture providing a sample social account."""
+    return SocialAccount(
+        username=sample_username,
+        platform="twitter",
+        profile_url="https://twitter.com/john_doe"
+    )
+
+def test_with_fixtures(sample_account):
+    """Test using fixtures."""
+    assert sample_account.username.value == "john_doe"
+    assert sample_account.platform == "twitter"
+```
+
+## Troubleshooting common issues
+
+### Import errors
+
+If you encounter import errors after creating your type, make sure you've run `poetry install` in the `flowsint-types` directory. The package needs to be reinstalled for changes to take effect:
+
+```bash
+cd flowsint-types
+poetry install
+```
+
+### Type not appearing in the API
+
+If your type doesn't appear in the API, verify that you've:
+1. Decorated it with `@flowsint_type`
+2. Imported it in `flowsint_types/__init__.py`
+3. Added it to the `__all__` list in `flowsint_types/__init__.py`
+4. Added it to the appropriate category in `_get_category_definitions()` in `flowsint-core/src/flowsint_core/core/services/type_registry_service.py`
+
+### Type not found in TYPE_REGISTRY
+
+If `TYPE_REGISTRY.get("MyType")` returns `None`:
+- Ensure the `@flowsint_type` decorator is applied to the class
+- Ensure the module is imported (either in `__init__.py` or via `load_all_types()`)
+- Check for import errors in your type file that prevent the module from loading
+
+### Validation errors
+
+For validation errors, check that you're using:
+- The ellipsis (`...`) for required fields
+- `None` for optional fields
+- `Optional[Type]` in type hints for optional fields
+
+### Nodes not appearing in the graph
+
+If your type's instances aren't appearing in Neo4j:
+- **Check the enricher**: Verify that enrichers using this type call `self.create_node(instance)`
+- **Check the created node**: Make sure the format of the created node is correct, no missing required field, etc.
+
+### Label not appearing correctly
+
+If labels aren't displaying correctly in the UI or graph:
+- **Missing compute_label**: Ensure you've implemented the `@model_validator(mode='after')` method
+- **Wrong field name**: Make sure you set `self.nodeLabel`, not `self.label`
+- **Not returning Self**: The method must return `self`
+- **None handling**: Check that you handle None values for optional fields gracefully
+- **Method name**: The method must be named `compute_label` exactly
+
+### Circular imports
+
+If you're seeing issues with circular imports:
+- Use relative imports (`from .email import Email`) instead of absolute imports
+- Use forward references (string type hints) if needed
+- Call `model_rebuild()` at the end of your module to resolve forward references
+
+### Enricher errors with your type
+
+If enrichers fail when using your type:
+- **Validation failures**: Your field validators might be too strict; check validator error messages in logs
+- **Nested object issues**: When passing nested Flowsint types, pass the complete object, don't recreate it
+- **Primary key extraction**: The graph service needs to extract a primitive value from your primary field
+
+## Next steps
+
+Once you've created and registered your type, you can use it in enrichers to build intelligence gathering workflows. Types serve as the input and output specifications for enrichers, and they define the structure of nodes in the Neo4j graph database.
+
+### Key checklist for new types
+
+Before considering your type complete, verify that you've:
+
+- Decorated with `@flowsint_type`
+- Inherited from `FlowsintType`
+- Marked exactly one field as primary with `json_schema_extra={"primary": True}`
+- Implemented `compute_label` method that sets `self.nodeLabel` and handles None values gracefully
+- Provided `description` and `title` for all fields
+- Used `default_factory` for list and dict fields
+- Written tests for creation, validation, primary field, and label computation
+- Exported your type in `flowsint_types/__init__.py`
+- Added it to a category in `flowsint-core/src/flowsint_core/core/services/type_registry_service.py`
+- Run `poetry install` to make the type available
+
+### Exploring further
+
+You might also want to explore:
+
+- **Creating enrichers**: Use your type as input/output in custom enrichers
+- **Custom types via API**: Flowsint supports runtime type creation using JSON Schema (see `flowsint-core/src/flowsint_core/core/models.py`)
+- **Graph format**: Learn about the [node and edge format](/docs/developers/graph-format) used in the frontend
+- **Type schemas**: Understand how Pydantic schemas are used for API validation
+
+### Final thoughts
+
+Remember that types are the foundation of everything in Flowsint:
+- **Well-designed types** make enrichers easier to write
+- **Clear primary fields** ensure proper node identification in the graph
+- **Meaningful labels** make the UI and graph database more intuitive
+- **Thorough validation** ensures data integrity throughout the platform
+
+With these concepts mastered, you're ready to create powerful, robust types that will make the entire Flowsint platform more effective for intelligence gathering.

+ 53 - 0
docs/getting-started/enrichers.mdx

@@ -0,0 +1,53 @@
+---
+title: "Enrichers"
+description: "Quick start guide to using Enrichers for your OSINT investigations."
+category: "Getting started"
+order: 4
+author: "Flowsint Team"
+tags: ["tutorial", "getting-started", "enrichers"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### What is an Enricher?
+
+An enricher is an operation that, starting from an input element A (source entity), produces one or more elements B (target entities) by applying a search or correlation method called a pivot.
+
+Example:
+
+```bash
+A = my.domain.com (domain name)
+    ↓
+p = “DNS resolution” (pivot)
+    ↓
+B = 12.23.34.45 (IP address)
+```
+
+That said, a pivot is the method or technical process used to derive B from A. The pivot defines how the transformation obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
+
+Example:
+
+```bash
+DNS Resolution → domain → IP
+    ↓
+WHOIS Lookup → IP → owner
+    ↓
+Reverse Image Search → image → web pages containing that image
+```
+
+Flowsint comes with a bunch of prebuilt enrichers, divided into multiple categories. Those enrichers can use standard pivots that your machine can support by default (DNS resolution, WHOIS request, etc.) and some other that depend on external tools. 
+
+Those can be : 
+
+- Native : DNS resolutions, Whois, etc.
+- Docker tools: [subfinder](https://github.com/projectdiscovery/subfinder), [asnmap](https://github.com/projectdiscovery/asnmap), etc.
+- Python tools: [sherlock](https://github.com/sherlock-project/sherlock), [maigret](https://github.com/soxoj/maigret), [reconurge/recontrack](https://github.com/reconurge/recontrack), [reconurge/reconcrawl](https://github.com/reconurge/reconcrawl), [reconurge/reconspread](https://github.com/reconurge/reconspread), etc. 
+- External services (paid or free): [shodan](https://www.shodan.io/), [whoxy](https://www.whoxy.com/), [whoisxmlapi](https://www.whoisxmlapi.com), etc.
+
+### Making your own enrichers
+
+Creating your own enrichers invloves multiple steps, but is not that trivial.
+
+If you plan on writting your own enrichers and think they could help the community, please contribute by making a pull request !
+
+Please refer to [this section](/docs/developers/managing-enrichers) to start building your own enrichers.

+ 159 - 0
docs/getting-started/flows.mdx

@@ -0,0 +1,159 @@
+---
+title: "Flows"
+description: "Quick start guide to using Flows for your OSINT investigations."
+category: "Getting started"
+order: 5
+author: "Flowsint Team"
+tags: ["tutorial", "getting-started", "flows"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### What are Flows?
+
+Flows are the chaining of multiple enrichers, where the output of one becomes the input of the next, allowing an investigation to be broadened or deepened.
+
+Some enrichers can be chained together, to that they can provide a reproductible and scalable research flow that can be re-applied to other entities of the same type.
+
+### Getting started with Flows
+
+The best way to get started with flows is to go to [http://localhost:5173/dashboard/flows](http://localhost:5173/dashboard/flows) and create a new flow.
+
+From that, you can start building your first flow; a flow consists of **one** input type, and multiple chained enrichers.
+
+Start by drag & dropping your input type to the canva and start using the "**+**" button to add more enrichers to your flow.
+
+Once you're satisfied with the flow, you can start viewing the execution order by pressing the "**compute**" button.
+
+Make sure you give it a descriptive name and save ! (`crtl + s` or presse the save button).
+
+Once it's done, go back to one of your sketches, right click on an item of the same type of the flow, and you should be able to launch it from the "flows" section.
+
+### Flow schema
+
+Every time a flow is launched, a log file is created at `flowsint_core/enricher_logs`.
+
+```json
+{
+  "sketch_id": "6aa808e4-1360-4c4a-b94f-4bed6c914836",
+  "scan_id": "52ba38a2-3f2c-4c8b-a7a5-42656f8fb845",
+  "created_at": "2025-10-26T15:57:52.243549",
+  "updated_at": "2025-10-26T15:57:52.424052",
+  "status": "completed",
+  "enricher_branches": [
+    {
+      "id": "branch-0",
+      "name": "Main Flow",
+      "steps": [
+        {
+          "nodeId": "Domain-1761469976169",
+          "params": {},
+          "type": "type",
+          "inputs": {},
+          "outputs": {
+            "domain": [
+              "example.com"
+            ]
+          },
+          "status": "pending",
+          "branchId": "branch-0",
+          "depth": 0
+        },
+        {
+          "nodeId": "domain_to_ip-1761469978018",
+          "params": {},
+          "type": "enricher",
+          "inputs": {
+            "Domain": null
+          },
+          "outputs": {
+            "address": "example.com",
+            "latitude": "example.com",
+            "longitude": "example.com",
+            "country": "example.com",
+            "city": "example.com",
+            "isp": "example.com"
+          },
+          "status": "pending",
+          "branchId": "branch-0",
+          "depth": 1
+        }
+      ]
+    }
+  ],
+  "execution_log": [
+    {
+      "step_id": "branch-0_domain_to_ip-1761469978018",
+      "branch_id": "branch-0",
+      "branch_name": "Main Flow",
+      "node_id": "domain_to_ip-1761469978018",
+      "enricher_name": "domain_to_ip",
+      "inputs": [
+        "example.com"
+      ],
+      "outputs": [
+        {
+          "address": "12.34.56.78",
+          "latitude": null,
+          "longitude": null,
+          "country": null,
+          "city": null,
+          "isp": null
+        }
+      ],
+      "status": "completed",
+      "error": null,
+      "timestamp": "2025-10-26T15:57:52.274147",
+      "execution_time_ms": 144,
+      "cache_hit": false
+    }
+  ],
+  "summary": {
+    "total_steps": 1,
+    "completed_steps": 1,
+    "failed_steps": 0,
+    "total_execution_time_ms": 144
+  },
+  "final_results": {
+    "initial_values": [
+      "example.com"
+    ],
+    "branches": [
+      {
+        "id": "branch-0",
+        "name": "Main Flow",
+        "steps": [
+          {
+            "nodeId": "domain_to_ip-1761469978018",
+            "enricher": "domain_to_ip",
+            "status": "completed",
+            "outputs": [
+              {
+                "address": "12.34.56.78",
+                "latitude": null,
+                "longitude": null,
+                "country": null,
+                "city": null,
+                "isp": null
+              }
+            ]
+          }
+        ]
+      }
+    ],
+    "results": {
+      "domain_to_ip-1761469978018": [
+        {
+          "address": "12.34.56.78",
+          "latitude": null,
+          "longitude": null,
+          "country": null,
+          "city": null,
+          "isp": null
+        }
+      ]
+    },
+    "reference_mapping": {}
+  }
+}
+```

+ 42 - 0
docs/getting-started/quickstart.mdx

@@ -0,0 +1,42 @@
+---
+title: "Installation"
+description: "Quick start guide to using Flowsint for your OSINT investigations."
+category: "Getting started"
+order: 3
+author: "Flowsint Team"
+tags: ["tutorial", "quickstart", "installation"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### Prerequisites
+
+Before installing Flowsint, ensure you have the following installed on your system:
+
+- **Docker** and **Docker Compose**
+- **Make** (for build automation)
+- **Git**
+
+### Installation
+
+Clone the repo and run the start command.
+
+```bash
+git clone https://github.com/reconurge/flowsint.git
+cd flowsint
+make prod
+```
+
+Some enrichers require API keys. Check out [this section](/docs/getting-started/enrichers#api-keys) to learn more.
+
+The application should automatically open at http://localhost:5173.
+
+### Create your first investigation
+
+Start by logging in or registering from the home page. From your dashboard, create a new investigation by clicking "New investigation." Add your first entity—such as a domain name—to the canvas. Right-click the entity to open the context menu and run an enricher. As results appear, explore the newly discovered entities and relationships directly in the graph.
+
+### Running your first enricher
+
+You could start by discovering subdomains for a domain for example.
+
+Add a domain entity like `example.com` to your investigation, then right-click the domain node and select the "domain_to_subdomains" enricher. Wait for the enricher to complete; newly discovered subdomains will be added to your graph for you to review.

+ 58 - 0
docs/getting-started/vault.mdx

@@ -0,0 +1,58 @@
+---
+title: "Vault"
+description: "Quick start guide to using the Vault to secure your services API keys and secrets."
+category: "Getting started"
+order: 6
+author: "Flowsint Team"
+tags: ["tutorial", "getting-started", "vault"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+## What is the Vault
+
+A good amount of the tools you'll be using in Flowsint require third party API keys.
+
+The **Vault** (*"Coffre fort"* in french) is the place to centralize and securely store those API keys. 
+Weither you have a local instance of Flowsint or one fully deployed on a distributed system, you need to have your keys securely stored.
+
+## Adding a key
+
+In the Flowsint enricher ecosystem, the API keys follow a specific format, being in uppercase letters, and with a declarative name that follows `<service>_API_KEY`.
+
+## Current limitations
+
+For now, we cannot match a particular key from the Vault to an enricher **directly from the UI**. The enricher declares the API key variable name it requires, like the following in the core of the Enricher:
+
+```python
+@classmethod
+    def get_params_schema(cls) -> List[Dict[str, Any]]:
+        """Declare required parameters for this enricher"""
+        return [
+            {
+                "name": "PDCP_API_KEY",
+                "type": "vaultSecret",
+                "description": "The ProjectDiscovery Cloud Platform API key for asnmap.",
+                "required": True,
+            },
+        ]
+```
+
+This is a known limitation and we are working on improving this. 
+
+In the meanwhile, here is a list of the needed keys to run Flowsint at it's full potential:
+
+```bash
+# for enrichers
+WHOXY_API_KEY # Whoxy domain search engine [WHOXY]
+PDCP_API_KEY # ProjectDiscovery Cloud Platform [ASNMAP], [NAABU] etc
+HIBP_API_KEY # HaveIBeenPwned API key [HIBP]
+ETHERSCAN_API_KEY # Etherscan crypto API key [ETHERSCAN]
+# for Flo, AI assistant
+MISTRAL_API_KEY
+# but other providers will be supported soon (ChatGPT, etc.)
+```
+
+There are also some other tools that could need a bunch of other API keys like [Subfinder](https://github.com/projectdiscovery/subfinder). Configuring them is not possible for now, but will be soon.
+
+Stay tuned for updates as those mechanisms may vary in the future, as the goal is to keep the user experience as smooth as possible.

+ 59 - 0
docs/overview.mdx

@@ -0,0 +1,59 @@
+---
+title: "Welcome to Flowsint"
+description: "Complete documentation for Flowsint - a modular OSINT investigation platform."
+category: "Overview"
+order: 1
+author: "Flowsint Team"
+tags: ["documentation", "overview", "getting-started"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### What is Flowsint?
+
+Flowsint is a modular investigation and reconnaissance platform focused on OSINT (Open Source Intelligence). It provides:
+
+- Graph-based visualization of entity relationships
+- 30+ automated enrichers for intelligence gathering
+- Modular architecture with clean separation of concerns
+- Privacy-first design with local data storage
+- Extensible platform for custom enrichers
+- Automated search flows (more on that [here](/docs/getting-started/flows))
+
+<Alert variant="warning">
+    <AlertTitle>Disclaimer !</AlertTitle>
+    <AlertDescription>
+    The author(s) and contributor(s) of this tool assume no responsibility or liability for any damages, losses, or consequences that may result from the use or misuse of this software.
+    By using this tool, you acknowledge and agree that:
+        - You are solely responsible for your use of this software
+        - You will use this tool in compliance with all applicable laws and regulations
+        - You will obtain proper authorization before conducting any security testing or reconnaissance activities
+        - You understand the potential risks and legal implications of using security tools
+</AlertDescription>
+  </Alert>
+
+### Why Flowsint?
+
+If you've already practiced some OSINT, you know that analysts often rely on a multitude of research tools: scripts, third-party services, specialized applications. But these tools often work **in silos** and quickly become obsolete if they're not maintained: a service disappears, an API changes, an access point closes. The analyst juggles with unstable tools and sometimes has to **manually adapt their data** to continue their investigation.
+
+OSINT tools, for most, are **consumables**: they evolve, appear, and disappear. Research methods change, security mechanisms evolve. However, the fundamental need always remains the same: **to see, exploit, and analyze data in a clear and understandable way**. Analysts need to be able to list, centralize, and visualize connections to have a clear understanding of their investigation.
+
+This is exactly what Flowsint was built for: a solid and durable foundation on which your investigations rest. Tools become simple extensions that plug in and unplug easily. A new tool comes out, and with a few manipulations it can be integrated into your investigation workflow.
+
+**Flowsint is the stable infrastructure that allows you to stay agile in the face of constant evolution of methods and sources.**
+
+Flowsint is a local tool, running on your machine only. This ensures a great level of confidentiality, but comes with responsabilities.
+
+<Alert variant="warning">
+    <AlertTitle>At your own risk</AlertTitle>
+    <AlertDescription>
+    All enrichers run locally on your machine. This means you can get banned from some services or get flagged from infrastructures if you start making thousands of requests to the same service.
+    You need to always know what you are doing, and understand that gathering can go very fast in scale.
+
+    For example, you can very easily happen to be making DNS resolution requests for 10 000 IPs.
+
+    Know you infrastructure and your limits. Know how the tools used in the enrichers actually work. You can have a list of the available enrichers and tools [here](/docs/sources/available-enrichers).
+</AlertDescription>
+  </Alert>
+
+If all those points are clear for you, let's start investigating !

+ 163 - 0
docs/sources/available-enrichers.mdx

@@ -0,0 +1,163 @@
+---
+title: "Enrichers catalog"
+description: "Quick start guide to using Enrichers for your OSINT investigations."
+category: "Sources"
+order: 11
+author: "Flowsint Team"
+tags: ["tutorial", "getting-started", "enrichers"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### ASN
+**asn_to_cidrs**: Given an ASN, enumerate its announced CIDR ranges.
+Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
+
+### CIDR
+**cidr_to_ips**: Expand a CIDR to IPs by PTR enumeration heuristics.
+Tools/Pivots: [dnsx](https://github.com/projectdiscovery/dnsx) (CLI)
+
+### Crypto
+**cryptowallet_to_transactions**: Fetch ETH wallet transactions and map wallet-to-wallet relationships.
+Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
+
+**cryptowallet_to_nfts**: Fetch ERC-721/1155 NFT transfers for a wallet.
+Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
+
+### Domain
+**domain_to_ip**: Resolve domains to IPv4 addresses.
+Tools/Pivots: DNS resolution (socket)
+
+**domain_to_subdomains**: Discover subdomains for a domain.
+Tools/APIs: [subfinder](https://github.com/projectdiscovery/subfinder) (CLI), fallback to [crt.sh JSON API](https://crt.sh/?output=json)
+
+**domain_to_whois**: Retrieve WHOIS registration data for a domain.
+Tools/APIs: [python-whois](https://pypi.org/project/python-whois/)
+
+**domain_to_asn**: Map a domain to its ASN by resolving and querying ASN data.
+Tools/Pivots: system DNS, [asnmap](https://github.com/projectdiscovery/asnmap) (CLI)
+
+**domain_to_root_domain**: Convert a subdomain to its registrable root.
+Tools/Pivots: internal domain utils
+
+**domain_to_history**: Retrieve historical WHOIS records and extract related entities (individuals, organizations, emails, phones, locations).
+Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
+
+**domain_to_website**: Convert a domain to a reachable website URL (HTTP/HTTPS), following redirects.
+Tools/Pivots: HTTP HEAD requests
+
+**domain_to_tls**: Retrieve TLS/SSL certificate information for a domain.
+Tools/Pivots: [httpx](https://github.com/projectdiscovery/httpx) (CLI)
+
+**domain_to_whois_history**: Retrieve historical WHOIS records for a domain and extract related entities (individuals, organizations, emails, locations).
+Tools/APIs: [WhoisXML API](https://whois.whoisxmlapi.com/)
+
+**domain_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a domain.
+Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
+
+### Email
+**email_to_breaches**: Check whether an email appears in known breaches.
+Tools/APIs: [Have I Been Pwned API](https://haveibeenpwned.com/API/v3)
+
+**email_to_gravatar**: Check Gravatar existence and profile for an email (via MD5 hash).
+Tools/APIs: [Gravatar endpoints](https://en.gravatar.com/site/implement/images/)
+
+**email_to_domain**: Extract the domain part of an email address.
+Tools/Pivots: internal email parser
+
+**email_to_domains**: Find domains registered by a given email address; extract related contacts and entities.
+Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
+
+**email_to_username**: Extract the local-part of an email as a Username entity.
+Tools/Pivots: internal email parser
+
+**email_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an email.
+Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
+
+**email_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with an email.
+Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
+
+### Individual
+**individual_to_domains**: Find domains registered by a specific person; extract related contacts and attributes.
+Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
+
+**individual_to_organization**: Find organizations related to a person in French registries.
+Tools/APIs: SIRENE (via internal SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
+
+### IP
+**ip_to_domain**: Reverse-resolve IPs to domains via PTR and Certificate Transparency pivots.
+Tools/APIs: DNS PTR (socket), [crt.sh JSON API](https://crt.sh/?output=json)
+
+**ip_to_infos**: Enrich IPs with geolocation and ISP data.
+Tools/APIs: [ip-api.com](https://ip-api.com/)
+
+**ip_to_asn**: Map IPs to their ASN.
+Tools/Pivots: AsnmapTool ([asnmap](https://github.com/projectdiscovery/asnmap))
+
+**ip_to_ports**: Scan an IP for open ports and services.
+Tools/Pivots: [naabu](https://github.com/projectdiscovery/naabu) (CLI)
+
+**ip_to_fraudscore**: Compute a fraud risk score for an IP address.
+Tools/APIs: [Scamalytics API](https://scamalytics.com/ip-api)
+
+**ip_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an IP.
+Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
+
+### Organization
+**org_to_domains**: Find domains registered by an organization; extract contacts and related entities.
+Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
+
+**org_to_infos**: Enrich organizations with French registry data and leaders.
+Tools/APIs: SIRENE (SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
+
+**org_to_asn**: Find ASNs associated with an organization name.
+Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
+
+### Phone
+**phone_to_infos**: Probe phone footprint across services (demo modules) and normalize number.
+Tools/APIs: ignorant modules (Amazon, Snapchat, Instagram), [httpx](https://github.com/projectdiscovery/httpx)
+
+**phone_to_carrier**: Look up carrier, country, and validity metadata for a phone number.
+Tools/APIs: [Veriphone API](https://veriphone.io/)
+
+**phone_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a phone number.
+Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
+
+### Social
+**username_to_socials_sherlock**: Enumerate social accounts for a username using Sherlock.
+Tools/Pivots: [sherlock](https://github.com/sherlock-project/sherlock) (CLI)
+
+**username_to_socials_maigret**: Enumerate social accounts for a username using Maigret and parse rich metadata.
+Tools/Pivots: [maigret](https://github.com/soxoj/maigret) (CLI)
+
+**username_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a username.
+Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
+
+**username_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a username.
+Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
+
+### Website
+**website_to_crawler**: Crawl a website to extract emails and phone numbers.
+Tools/APIs: ReconCrawlTool (`reconcrawl`)
+
+**website_to_domain**: Extract the domain name from a website URL.
+Tools/Pivots: internal URL parser
+
+**website_to_subdomains**: Find subdomains of a website's domain via external scan.
+Tools/APIs: [c99.nl API](https://api.c99.nl/)
+
+**website_to_links**: Crawl a website and collect internal/external links and domains.
+Tools/APIs: reconspread Crawler
+
+**website_to_text**: Fetch and extract visible text from a webpage.
+Tools/APIs: HTTP GET, [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)
+
+**website_to_webtrackers**: Extract analytics/ads tracking codes from a website.
+Tools/APIs: recontrack TrackingCodeExtractor
+
+---
+
+Notes
+- Some enrichers optionally depend on docker binaries: `subfinder`, `asnmap`, `dnsx`, `naabu`, `httpx`, and `jq` which are installed in the docker container.
+- API-keyed enrichers read keys from params or environment (e.g., `HIBP_API_KEY`, `ETHERSCAN_API_KEY`, `WHOXY_API_KEY`, `WHOISXML_API_KEY`, `DEHASHED_API_KEY`, `SCAMALYTICS_API_KEY`, `VERIPHONE_API_KEY`, `C99_API_KEY`).
+- Internal/test enrichers (`domain_to_dummy`, `ip_to_dummy_domains`, `n8n_connector`) are not listed here — they exist in the codebase but are not part of the public catalog.

+ 73 - 0
docs/syllabus.mdx

@@ -0,0 +1,73 @@
+---
+title: "Syllabus"
+description: "Syllabus, just to make sure we speak the same language. Those definitions apply in the context of Flowsint platform."
+category: "Overview"
+order: 2
+author: "Flowsint Team"
+tags: ["documentation", "overview",  "syllabus"]
+version: "1.2.8"
+last_updated_at: "2026-05-15"
+---
+
+### OSINT
+
+      Open Source Intelligence consists of collecting, analyzing, and exploiting **freely** and **openly** available information from search engines, images, social networks, public archives, etc.
+
+### Investigation
+
+    A structured process aimed at collecting, correlating, and analyzing information from different sources and enrichers, in order to answer a question or solve a problem. An investigation can be **exploratory** (discovering unknown elements) or **targeted** (validating a hypothesis). An investigation can contain multiple **sketches** (each representing a different view or stage of the analysis) and one or more **analyses**.
+
+### Sketch
+
+    Visual result produced by executing one or more enrichers on one or more entities. A sketch represents the current state of the graph derived from collected data at a given moment in the investigation. Multiple sketches can exist for the same investigation to capture different perspectives or stages.
+
+### Analysis
+
+    Set of processing, interpretations, and verifications performed on data collected during the investigation. Analyses aim to identify trends, confirm or refute hypotheses, and produce actionable conclusions. They can be **quantitative** (measurements, statistics) or **qualitative** (contextual assessments, behavioral patterns).
+
+### Enricher
+
+    An **enricher** is an operation that, from an input element **A** (*source entity*), allows obtaining one or more elements **B** (*target entities*) by applying a search or correlation method called a **pivot**.
+
+    > Example:
+    >
+    >
+    > A = `my.domain.com` (*domain name*)
+    >
+    > p = "DNS resolution" (*pivot*)
+    >
+    > B = `12.23.34.45` (*IP address*).
+    >
+### Pivot
+
+    A **pivot** is the method or technical process used to derive **B** from **A**. The pivot defines **how** the enricher obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
+
+    > Examples of pivots:
+    >
+    > DNS Resolution → domain → IP
+    > WHOIS Lookup → IP → owner
+    > Reverse Image Search → image → web pages containing this image
+
+### Tool
+
+    A tool generally refers to a script, program, or service providing a **pivot**, i.e., a means to retrieve or enricher information from an input element.
+
+### Entity
+
+    An identifiable object or element manipulated by enrichers (e.g., IP address, domain, email address, user identifier, file hash, etc.). An entity is always associated with a **Sketch**. In the graph, entities are represented as **nodes** (see [Graph format](/docs/developers/graph-format) for technical details).
+
+### Relationship
+
+    Defines a link between two entities. This link is generally named (in uppercase) and can be unidirectional or bidirectional.
+
+    > Examples of relationships:
+    >
+    >
+    > A = `my.domain.com` → `RESOLVES_TO` → `12.23.34.45`
+    >
+
+    A relationship is always associated between a **source** node (*from*) and a **target** node (*to*). In the graph, relationships are represented as **edges** (see [Graph format](/docs/developers/graph-format) for technical details).
+
+### Flow
+
+    The chaining of multiple enrichers, where the output of one becomes the input of the next, allowing to expand or deepen an investigation.

+ 176 - 0
flowsint-api/.gitignore

@@ -0,0 +1,176 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+# lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+enricher_logs

+ 123 - 0
flowsint-api/Dockerfile

@@ -0,0 +1,123 @@
+FROM python:3.12-slim AS builder
+
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1 \
+    UV_COMPILE_BYTECODE=1 \
+    UV_LINK_MODE=copy
+
+WORKDIR /app
+
+# build deps + uv
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    build-essential \
+    curl \
+    git \
+    libpq-dev \
+    pkg-config \
+    libcairo2-dev \
+    && rm -rf /var/lib/apt/lists/* \
+    && curl -LsSf https://astral.sh/uv/install.sh | sh
+
+ENV PATH="/root/.local/bin:$PATH"
+
+# Copy workspace config
+COPY pyproject.toml uv.lock ./
+
+# Copy all workspace members
+COPY flowsint-types ./flowsint-types
+COPY flowsint-core ./flowsint-core
+COPY flowsint-enrichers ./flowsint-enrichers
+COPY flowsint-api ./flowsint-api
+
+RUN uv sync --frozen --no-dev
+
+# DEV
+FROM python:3.12-slim AS dev
+
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1 \
+    APP_ENV=development \
+    PATH="/app/.venv/bin:$PATH"
+
+# Install runtime dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libpq5 \
+    libcairo2 \
+    curl \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy virtual environment from builder
+COPY --from=builder /app/.venv ./.venv
+
+# Copy application code
+COPY flowsint-core ./flowsint-core
+COPY flowsint-types ./flowsint-types
+COPY flowsint-enrichers ./flowsint-enrichers
+COPY flowsint-api ./flowsint-api
+
+WORKDIR /app/flowsint-api
+
+# Make entrypoint executable
+RUN chmod +x entrypoint.sh
+
+EXPOSE 5001
+
+ENTRYPOINT ["./entrypoint.sh"]
+
+# Dev command with hot-reload
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
+
+# PROD
+FROM python:3.12-slim AS production
+
+LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
+LABEL org.opencontainers.image.description="Flowsint API & Worker"
+LABEL org.opencontainers.image.licenses="Apache-2.0"
+
+ENV PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1 \
+    APP_ENV=production \
+    PATH="/app/.venv/bin:$PATH"
+
+# Install runtime dependencies only
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libpq5 \
+    libcairo2 \
+    curl \
+    && rm -rf /var/lib/apt/lists/* \
+    && apt-get clean
+
+# Create non-root user
+RUN groupadd -g 1001 flowsint && \
+    useradd -u 1001 -g flowsint -s /bin/bash -m flowsint
+
+WORKDIR /app
+
+# Copy virtual environment from builder
+COPY --from=builder --chown=flowsint:flowsint /app/.venv ./.venv
+
+# Copy application code
+COPY --chown=flowsint:flowsint flowsint-core ./flowsint-core
+COPY --chown=flowsint:flowsint flowsint-types ./flowsint-types
+COPY --chown=flowsint:flowsint flowsint-enrichers ./flowsint-enrichers
+COPY --chown=flowsint:flowsint flowsint-api ./flowsint-api
+
+WORKDIR /app/flowsint-api
+
+# Make entrypoint executable
+RUN chmod +x entrypoint.sh
+
+# Switch to non-root user
+USER flowsint
+
+EXPOSE 5001
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+    CMD curl -f http://localhost:5001/health || exit 1
+
+ENTRYPOINT ["./entrypoint.sh"]
+
+# Production command (no reload)
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]

+ 18 - 0
flowsint-api/README.md

@@ -0,0 +1,18 @@
+# flowsint-api
+
+## Installation
+
+1. Install Python dependencies:
+2. 
+```bash
+uv sync
+```
+
+## Run
+
+```bash
+# dev
+uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
+# prod
+uv run uvicorn app.main:app --host 0.0.0.0 --port 5001
+```

+ 38 - 0
flowsint-api/alembic.ini

@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+sqlalchemy.url = postgresql://flowsint:flowsint@localhost:5433/flowsint
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+propagate = 0
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+propagate = 0
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s

+ 1 - 0
flowsint-api/alembic/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 57 - 0
flowsint-api/alembic/env.py

@@ -0,0 +1,57 @@
+import os
+import sys
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config, pool
+from alembic import context
+from dotenv import load_dotenv
+
+load_dotenv()
+
+sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
+
+from flowsint_core.core.models import *  # noqa
+
+config = context.config
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+database_url = os.getenv("DATABASE_URL")
+if not database_url:
+    raise RuntimeError("DATABASE_URL is not defined in .env")
+config.set_main_option("sqlalchemy.url", database_url)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    """Run migrations in 'offline' mode."""
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    """Run migrations in 'online' mode."""
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(connection=connection, target_metadata=target_metadata)
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 28 - 0
flowsint-api/alembic/script.py.mako

@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    ${downgrades if downgrades else "pass"}

+ 32 - 0
flowsint-api/alembic/versions/0160b0f70a02_add_context_to_chat_message.py

@@ -0,0 +1,32 @@
+"""add context to chat message
+
+Revision ID: 0160b0f70a02
+Revises: 8ac522441108
+Create Date: 2025-07-11 17:34:54.233560
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '0160b0f70a02'
+down_revision: Union[str, None] = '8ac522441108'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('messages', sa.Column('context', sa.JSON(), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('messages', 'context')
+    # ### end Alembic commands ###

+ 32 - 0
flowsint-api/alembic/versions/0ab8ee0a782c_add_cascade_delete_to_messages.py

@@ -0,0 +1,32 @@
+"""add cascade delete to messages
+
+Revision ID: 0ab8ee0a782c
+Revises: 0160b0f70a02
+Create Date: 2025-07-12 11:25:03.863631
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '0ab8ee0a782c'
+down_revision: Union[str, None] = '0160b0f70a02'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 55 - 0
flowsint-api/alembic/versions/1098b7a5eabc_change_keys_structure_with_iv_salt_.py

@@ -0,0 +1,55 @@
+"""change keys structure with iv, salt, version and cypher
+
+Revision ID: 1098b7a5eabc
+Revises: 661ff8ef4425
+Create Date: 2025-09-04 19:48:43.467378
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '1098b7a5eabc'
+down_revision: Union[str, None] = '661ff8ef4425'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    op.execute("TRUNCATE TABLE keys")
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('keys', sa.Column('ciphertext', sa.LargeBinary(), nullable=False))
+    op.add_column('keys', sa.Column('iv', sa.LargeBinary(), nullable=False))
+    op.add_column('keys', sa.Column('salt', sa.LargeBinary(), nullable=False))
+    op.add_column('keys', sa.Column('key_version', sa.String(), nullable=False))
+    op.alter_column('keys', 'owner_id',
+               existing_type=sa.UUID(),
+               nullable=False)
+    op.alter_column('keys', 'created_at',
+               existing_type=postgresql.TIMESTAMP(timezone=True),
+               nullable=False,
+               existing_server_default=sa.text('now()'))
+    op.drop_column('keys', 'encrypted_key')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('keys', sa.Column('encrypted_key', sa.VARCHAR(), autoincrement=False, nullable=False))
+    op.alter_column('keys', 'created_at',
+               existing_type=postgresql.TIMESTAMP(timezone=True),
+               nullable=True,
+               existing_server_default=sa.text('now()'))
+    op.alter_column('keys', 'owner_id',
+               existing_type=sa.UUID(),
+               nullable=True)
+    op.drop_column('keys', 'key_version')
+    op.drop_column('keys', 'salt')
+    op.drop_column('keys', 'iv')
+    op.drop_column('keys', 'ciphertext')
+    # ### end Alembic commands ###

+ 32 - 0
flowsint-api/alembic/versions/1d0f26dbbef5_add_passive_delete_v2.py

@@ -0,0 +1,32 @@
+"""add passive_delete_v2
+
+Revision ID: 1d0f26dbbef5
+Revises: afdaf9aa539c
+Create Date: 2025-09-17 22:48:31.379106
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '1d0f26dbbef5'
+down_revision: Union[str, None] = 'afdaf9aa539c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 40 - 0
flowsint-api/alembic/versions/2da47dbd4a52_add_cascade_delete_to_scans_and_logs.py

@@ -0,0 +1,40 @@
+"""add cascade delete to scans and logs
+
+Revision ID: 2da47dbd4a52
+Revises: 71a3e5b4db2a
+Create Date: 2025-06-08 22:09:41.393963
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '2da47dbd4a52'
+down_revision: Union[str, None] = '71a3e5b4db2a'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column('scans', 'status',
+               existing_type=postgresql.ENUM('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', name='transformstatus'),
+               nullable=True)
+    op.drop_constraint('scans_sketch_id_fkey', 'scans', type_='foreignkey')
+    op.create_foreign_key(None, 'scans', 'sketches', ['sketch_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint(None, 'scans', type_='foreignkey')
+    op.create_foreign_key('scans_sketch_id_fkey', 'scans', 'sketches', ['sketch_id'], ['id'])
+    op.alter_column('scans', 'status',
+               existing_type=postgresql.ENUM('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', name='transformstatus'),
+               nullable=False)
+    # ### end Alembic commands ###

+ 38 - 0
flowsint-api/alembic/versions/40ece72583b7_add_email_and_hashed_password_to_profile.py

@@ -0,0 +1,38 @@
+"""Add email and hashed_password to profile
+
+Revision ID: 40ece72583b7
+Revises: 965b56353b4c
+Create Date: 2025-05-19 15:37:27.466187
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '40ece72583b7'
+down_revision: Union[str, None] = '965b56353b4c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('profiles', sa.Column('email', sa.String(), nullable=False))
+    op.add_column('profiles', sa.Column('hashed_password', sa.String(), nullable=False))
+    op.add_column('profiles', sa.Column('is_active', sa.Boolean(), nullable=False))
+    op.create_unique_constraint(None, 'profiles', ['email'])
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint(None, 'profiles', type_='unique')
+    op.drop_column('profiles', 'is_active')
+    op.drop_column('profiles', 'hashed_password')
+    op.drop_column('profiles', 'email')
+    # ### end Alembic commands ###

+ 36 - 0
flowsint-api/alembic/versions/661ff8ef4425_rename_transforms_to_flows.py

@@ -0,0 +1,36 @@
+"""rename_transforms_to_flows
+
+Revision ID: 661ff8ef4425
+Revises: 9a3b9a199aa8
+Create Date: 2025-08-15 16:16:12.792775
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '661ff8ef4425'
+down_revision: Union[str, None] = '9a3b9a199aa8'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # Rename the table from 'transforms' to 'flows'
+    op.rename_table('transforms', 'flows')
+    
+    # Rename the column from 'transform_schema' to 'flow_schema'
+    op.alter_column('flows', 'transform_schema', new_column_name='flow_schema')
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # Rename the column back from 'flow_schema' to 'transform_schema'
+    op.alter_column('flows', 'flow_schema', new_column_name='transform_schema')
+    
+    # Rename the table back from 'flows' to 'transforms'
+    op.rename_table('flows', 'transforms')

+ 35 - 0
flowsint-api/alembic/versions/6be831edfda7_add_investigation_roles_permissions.py

@@ -0,0 +1,35 @@
+"""add investigation roles permissions
+
+
+Revision ID: 6be831edfda7
+Revises: c82bf6af92e5
+Create Date: 2025-09-17 22:02:46.159090
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '6be831edfda7'
+down_revision: Union[str, None] = 'c82bf6af92e5'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('investigation_user_roles', sa.Column('roles', postgresql.ARRAY(sa.Enum('OWNER', 'EDITOR', 'VIEWER', name='role_enum', create_constraint=True)), server_default='{}', nullable=False))
+    op.drop_column('investigation_user_roles', 'role')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('investigation_user_roles', sa.Column('role', postgresql.ENUM('OWNER', 'EDITOR', 'VIEWER', name='role_enum'), autoincrement=False, nullable=False))
+    op.drop_column('investigation_user_roles', 'roles')
+    # ### end Alembic commands ###

+ 90 - 0
flowsint-api/alembic/versions/6dfa83113ad7_change_content_colum_of_log_to_json.py

@@ -0,0 +1,90 @@
+"""change content column of Log to JSON
+
+Revision ID: 6dfa83113ad7
+Revises: ba3d00e11612
+Create Date: 2025-06-18 17:42:28.391884
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '6dfa83113ad7'
+down_revision: Union[str, None] = 'ba3d00e11612'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # 1. Crée le nouveau type ENUM
+    op.execute("""
+        CREATE TYPE eventlevel AS ENUM (
+            'INFO', 'WARNING', 'FAILED', 'SUCCESS', 'DEBUG',
+            'PENDING', 'RUNNING', 'COMPLETED', 'GRAPH_APPEND'
+        )
+    """)
+
+    # 2. Change 'logs.content' de TEXT à JSONB
+    op.execute("""
+        ALTER TABLE logs
+        ALTER COLUMN content TYPE JSONB
+        USING CASE
+            WHEN content IS NULL THEN 'null'::jsonb
+            ELSE content::jsonb
+        END
+    """)
+
+    # 3. Supprime le DEFAULT sur logs.type
+    op.execute("""
+        ALTER TABLE logs ALTER COLUMN type DROP DEFAULT
+    """)
+
+    # 4. Change le type de logs.type vers le nouvel ENUM
+    op.execute("""
+        ALTER TABLE logs
+        ALTER COLUMN type TYPE eventlevel
+        USING type::eventlevel
+    """)
+
+    # 5. Réapplique le DEFAULT avec le bon type
+    op.execute("""
+        ALTER TABLE logs ALTER COLUMN type SET DEFAULT 'INFO'
+    """)
+
+    # 6. Change scans.status vers le même ENUM
+    op.execute("""
+        ALTER TABLE scans
+        ALTER COLUMN status TYPE eventlevel
+        USING status::text::eventlevel
+    """)
+
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # 1. Revert 'scans.status' to old ENUM type
+    op.execute("""
+        ALTER TABLE scans
+        ALTER COLUMN status TYPE transformstatus
+        USING status::text::transformstatus
+    """)
+
+    # 2. Revert 'logs.type' back to VARCHAR
+    op.execute("""
+        ALTER TABLE logs
+        ALTER COLUMN type TYPE VARCHAR
+        USING type::text
+    """)
+
+    # 3. Revert 'logs.content' back to TEXT
+    op.execute("""
+        ALTER TABLE logs
+        ALTER COLUMN content TYPE TEXT
+        USING content::text
+    """)
+
+    # 4. Drop the new ENUM type
+    op.execute("DROP TYPE eventlevel")

+ 63 - 0
flowsint-api/alembic/versions/6e49acfb3816_add_investigation_roles_permissions.py

@@ -0,0 +1,63 @@
+"""add investigation roles permissions
+
+
+Revision ID: 6e49acfb3816
+Revises: 1098b7a5eabc
+Create Date: 2025-09-17 21:46:14.314402
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '6e49acfb3816'
+down_revision: Union[str, None] = '1098b7a5eabc'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('investigation_user_roles',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('user_id', sa.UUID(), nullable=False),
+    sa.Column('investigation_id', sa.UUID(), nullable=False),
+    sa.Column('role', sa.Enum('OWNER', 'EDITOR', 'VIEWER', name='role_enum', create_constraint=True), nullable=False),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('user_id', 'investigation_id', name='uq_user_investigation')
+    )
+    op.create_index('idx_investigation_roles_investigation_id', 'investigation_user_roles', ['investigation_id'], unique=False)
+    op.create_index('idx_investigation_roles_user_id', 'investigation_user_roles', ['user_id'], unique=False)
+    op.drop_index('idx_investigations_profiles_investigation_id', table_name='investigations_profiles')
+    op.drop_index('idx_investigations_profiles_profile_id', table_name='investigations_profiles')
+    op.drop_index('projects_profiles_unique_profile_project', table_name='investigations_profiles')
+    op.drop_table('investigations_profiles')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('investigations_profiles',
+    sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
+    sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
+    sa.Column('investigation_id', sa.UUID(), autoincrement=False, nullable=True),
+    sa.Column('profile_id', sa.UUID(), autoincrement=False, nullable=True),
+    sa.Column('role', sa.VARCHAR(), server_default=sa.text("'member'::character varying"), autoincrement=False, nullable=True),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], name='investigations_profiles_investigation_id_fkey', onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], name='investigations_profiles_profile_id_fkey', onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name='investigations_profiles_pkey')
+    )
+    op.create_index('projects_profiles_unique_profile_project', 'investigations_profiles', ['profile_id', 'investigation_id'], unique=False)
+    op.create_index('idx_investigations_profiles_profile_id', 'investigations_profiles', ['profile_id'], unique=False)
+    op.create_index('idx_investigations_profiles_investigation_id', 'investigations_profiles', ['investigation_id'], unique=False)
+    op.drop_index('idx_investigation_roles_user_id', table_name='investigation_user_roles')
+    op.drop_index('idx_investigation_roles_investigation_id', table_name='investigation_user_roles')
+    op.drop_table('investigation_user_roles')
+    # ### end Alembic commands ###

+ 103 - 0
flowsint-api/alembic/versions/71a3e5b4db2a_update_scan_status_enum.py

@@ -0,0 +1,103 @@
+"""update_scan_status_enum
+
+Revision ID: 71a3e5b4db2a
+Revises: faceebd6a580
+Create Date: 2025-06-08 16:29:38.093854
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '71a3e5b4db2a'
+down_revision: Union[str, None] = 'faceebd6a580'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # Create the enum type first using raw SQL to ensure it exists
+    op.execute("CREATE TYPE transformstatus AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')")
+
+    # Add new columns
+    op.add_column('scans', sa.Column('started_at', sa.DateTime(), nullable=True))
+    op.add_column('scans', sa.Column('completed_at', sa.DateTime(), nullable=True))
+    op.add_column('scans', sa.Column('error', sa.Text(), nullable=True))
+    op.add_column('scans', sa.Column('details', sa.JSON(), nullable=True))
+    
+    # Add new status column with enum type
+    op.add_column('scans', sa.Column('status_new', postgresql.ENUM('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', name='transformstatus'), nullable=True))
+    
+    # Copy data from old status to new status with proper casting
+    op.execute("""
+        UPDATE scans 
+        SET status_new = CASE 
+            WHEN status = 'PENDING' THEN 'PENDING'::transformstatus
+            WHEN status = 'RUNNING' THEN 'RUNNING'::transformstatus
+            WHEN status = 'COMPLETED' THEN 'COMPLETED'::transformstatus
+            WHEN status = 'FAILED' THEN 'FAILED'::transformstatus
+            ELSE 'PENDING'::transformstatus
+        END
+    """)
+    
+    # Make the new status column not nullable
+    op.alter_column('scans', 'status_new', nullable=False)
+    
+    # Drop the old status column
+    op.drop_column('scans', 'status')
+    
+    # Rename the new status column
+    op.alter_column('scans', 'status_new', new_column_name='status')
+    
+    # Update other columns
+    op.alter_column('scans', 'sketch_id',
+               existing_type=sa.UUID(),
+               nullable=False)
+    op.drop_index('idx_scans_sketch_id', table_name='scans')
+    op.drop_constraint('scans_sketch_id_fkey', 'scans', type_='foreignkey')
+    op.create_foreign_key(None, 'scans', 'sketches', ['sketch_id'], ['id'])
+    op.drop_column('scans', 'values')
+    op.drop_column('scans', 'results')
+    op.drop_column('scans', 'created_at')
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # Add back old columns
+    op.add_column('scans', sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True))
+    op.add_column('scans', sa.Column('results', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
+    op.add_column('scans', sa.Column('values', postgresql.ARRAY(sa.TEXT()), autoincrement=False, nullable=True))
+    
+    # Add new VARCHAR status column
+    op.add_column('scans', sa.Column('status_old', sa.String(), nullable=True))
+    
+    # Copy data from enum status to VARCHAR status
+    op.execute("UPDATE scans SET status_old = status::VARCHAR")
+    
+    # Make the new status column not nullable
+    op.alter_column('scans', 'status_old', nullable=False)
+    
+    # Drop the enum status column
+    op.drop_column('scans', 'status')
+    
+    # Rename the new status column
+    op.alter_column('scans', 'status_old', new_column_name='status')
+    
+    # Update other columns
+    op.drop_constraint(None, 'scans', type_='foreignkey')
+    op.create_foreign_key('scans_sketch_id_fkey', 'scans', 'sketches', ['sketch_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
+    op.create_index('idx_scans_sketch_id', 'scans', ['sketch_id'], unique=False)
+    op.alter_column('scans', 'sketch_id',
+               existing_type=sa.UUID(),
+               nullable=True)
+    op.drop_column('scans', 'details')
+    op.drop_column('scans', 'error')
+    op.drop_column('scans', 'completed_at')
+    op.drop_column('scans', 'started_at')
+
+    # Drop the enum type
+    op.execute("DROP TYPE transformstatus") 

+ 32 - 0
flowsint-api/alembic/versions/76f5436251e3_add_relationship_between_investigations_.py

@@ -0,0 +1,32 @@
+"""Add relationship between investigations and sketches
+
+Revision ID: 76f5436251e3
+Revises: d0a8e5b5a7b9
+Create Date: 2025-05-19 23:59:33.721812
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '76f5436251e3'
+down_revision: Union[str, None] = 'd0a8e5b5a7b9'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 50 - 0
flowsint-api/alembic/versions/8173aba964e7_add_custom_types_table.py

@@ -0,0 +1,50 @@
+"""add custom_types table
+
+Revision ID: 8173aba964e7
+Revises: 8d0e12b68d1e
+Create Date: 2025-11-06 11:15:59.143311
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '8173aba964e7'
+down_revision: Union[str, None] = '8d0e12b68d1e'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('custom_types',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('name', sa.Text(), nullable=False),
+    sa.Column('owner_id', sa.UUID(), nullable=False),
+    sa.Column('schema', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+    sa.Column('status', sa.String(), server_default='draft', nullable=False),
+    sa.Column('checksum', sa.String(), nullable=True),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_custom_types_name', 'custom_types', ['name'], unique=False)
+    op.create_index('idx_custom_types_owner_id', 'custom_types', ['owner_id'], unique=False)
+    op.create_index('idx_custom_types_status', 'custom_types', ['status'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('idx_custom_types_status', table_name='custom_types')
+    op.drop_index('idx_custom_types_owner_id', table_name='custom_types')
+    op.drop_index('idx_custom_types_name', table_name='custom_types')
+    op.drop_table('custom_types')
+    # ### end Alembic commands ###

+ 59 - 0
flowsint-api/alembic/versions/8ac522441108_add_chat_and_chat_message.py

@@ -0,0 +1,59 @@
+"""add chat and chat message
+
+Revision ID: 8ac522441108
+Revises: e403a4152f6b
+Create Date: 2025-07-11 16:27:23.975758
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '8ac522441108'
+down_revision: Union[str, None] = 'e403a4152f6b'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('chats',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('title', sa.Text(), nullable=False),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('last_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.Column('investigation_id', sa.UUID(), nullable=True),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_chats_investigation_id', 'chats', ['investigation_id'], unique=False)
+    op.create_index('idx_chats_owner_id', 'chats', ['owner_id'], unique=False)
+    op.create_table('messages',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('content', sa.JSON(), nullable=True),
+    sa.Column('is_bot', sa.Boolean(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('chat_id', sa.UUID(), nullable=False),
+    sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_messages_chat_id', 'messages', ['chat_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('idx_messages_chat_id', table_name='messages')
+    op.drop_table('messages')
+    op.drop_index('idx_chats_owner_id', table_name='chats')
+    op.drop_index('idx_chats_investigation_id', table_name='chats')
+    op.drop_table('chats')
+    # ### end Alembic commands ###

+ 32 - 0
flowsint-api/alembic/versions/8d0e12b68d1e_fix_backpopulate_issue.py

@@ -0,0 +1,32 @@
+"""fix_backpopulate issue
+
+Revision ID: 8d0e12b68d1e
+Revises: 1d0f26dbbef5
+Create Date: 2025-09-17 22:55:20.721587
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '8d0e12b68d1e'
+down_revision: Union[str, None] = '1d0f26dbbef5'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 151 - 0
flowsint-api/alembic/versions/965b56353b4c_initial_migration.py

@@ -0,0 +1,151 @@
+"""initial migration
+
+Revision ID: 965b56353b4c
+Revises: 
+Create Date: 2025-05-19 14:35:04.755433
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '965b56353b4c'
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('profiles',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('first_name', sa.Text(), nullable=True),
+    sa.Column('last_name', sa.Text(), nullable=True),
+    sa.Column('avatar_url', sa.Text(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('transforms',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('name', sa.Text(), nullable=False),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('category', postgresql.ARRAY(sa.Text()), nullable=True),
+    sa.Column('transform_schema', sa.JSON(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('last_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('feedbacks',
+    sa.Column('id', sa.Uuid(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('content', sa.Text(), nullable=True),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_table('investigations',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('name', sa.Text(), nullable=True),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.Column('last_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('status', sa.String(), server_default='active', nullable=True),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_investigations_id', 'investigations', ['id'], unique=False)
+    op.create_index('idx_investigations_owner_id', 'investigations', ['owner_id'], unique=False)
+    op.create_table('investigations_profiles',
+    sa.Column('id', sa.Uuid(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('investigation_id', sa.UUID(), nullable=True),
+    sa.Column('profile_id', sa.UUID(), nullable=True),
+    sa.Column('role', sa.String(), server_default='member', nullable=True),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_investigations_profiles_investigation_id', 'investigations_profiles', ['investigation_id'], unique=False)
+    op.create_index('idx_investigations_profiles_profile_id', 'investigations_profiles', ['profile_id'], unique=False)
+    op.create_index('projects_profiles_unique_profile_project', 'investigations_profiles', ['profile_id', 'investigation_id'], unique=True)
+    op.create_table('sketches',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('title', sa.Text(), nullable=True),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.Column('status', sa.String(), server_default='active', nullable=True),
+    sa.Column('investigation_id', sa.UUID(), nullable=True),
+    sa.Column('last_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_sketches_investigation_id', 'sketches', ['investigation_id'], unique=False)
+    op.create_index('idx_sketches_owner_id', 'sketches', ['owner_id'], unique=False)
+    op.create_table('scans',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('status', sa.String(), nullable=True),
+    sa.Column('results', sa.JSON(), nullable=True),
+    sa.Column('values', postgresql.ARRAY(sa.Text()), nullable=True),
+    sa.Column('sketch_id', sa.UUID(), nullable=True),
+    sa.ForeignKeyConstraint(['sketch_id'], ['sketches.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_scans_sketch_id', 'scans', ['sketch_id'], unique=False)
+    op.create_table('sketches_profiles',
+    sa.Column('id', sa.Uuid(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('profile_id', sa.UUID(), nullable=True),
+    sa.Column('sketch_id', sa.UUID(), nullable=True),
+    sa.Column('role', sa.String(), server_default='editor', nullable=True),
+    sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['sketch_id'], ['sketches.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_sketches_profiles_profile_id', 'sketches_profiles', ['profile_id'], unique=False)
+    op.create_index('idx_sketches_profiles_sketch_id', 'sketches_profiles', ['sketch_id'], unique=False)
+    op.create_index('investigations_profiles_unique_profile_investigation', 'sketches_profiles', ['profile_id', 'sketch_id'], unique=True)
+    op.create_table('logs',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('scan_id', sa.UUID(), nullable=True),
+    sa.Column('content', sa.Text(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('sketch_id', sa.UUID(), nullable=True),
+    sa.Column('type', sa.String(), server_default='INFO', nullable=True),
+    sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['sketch_id'], ['sketches.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('logs')
+    op.drop_index('investigations_profiles_unique_profile_investigation', table_name='sketches_profiles')
+    op.drop_index('idx_sketches_profiles_sketch_id', table_name='sketches_profiles')
+    op.drop_index('idx_sketches_profiles_profile_id', table_name='sketches_profiles')
+    op.drop_table('sketches_profiles')
+    op.drop_index('idx_scans_sketch_id', table_name='scans')
+    op.drop_table('scans')
+    op.drop_index('idx_sketches_owner_id', table_name='sketches')
+    op.drop_index('idx_sketches_investigation_id', table_name='sketches')
+    op.drop_table('sketches')
+    op.drop_index('projects_profiles_unique_profile_project', table_name='investigations_profiles')
+    op.drop_index('idx_investigations_profiles_profile_id', table_name='investigations_profiles')
+    op.drop_index('idx_investigations_profiles_investigation_id', table_name='investigations_profiles')
+    op.drop_table('investigations_profiles')
+    op.drop_index('idx_investigations_owner_id', table_name='investigations')
+    op.drop_index('idx_investigations_id', table_name='investigations')
+    op.drop_table('investigations')
+    op.drop_table('feedbacks')
+    op.drop_table('transforms')
+    op.drop_table('profiles')
+    # ### end Alembic commands ###

+ 58 - 0
flowsint-api/alembic/versions/9a3b9a199aa8_drop_third_party_keys_create_keys_table.py

@@ -0,0 +1,58 @@
+"""drop_third_party_keys_create_keys_table
+
+Revision ID: 9a3b9a199aa8
+Revises: 0ab8ee0a782c
+Create Date: 2025-07-20 12:13:27.871890
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '9a3b9a199aa8'
+down_revision: Union[str, None] = '0ab8ee0a782c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # Drop the old third_party_keys table if it exists
+    op.drop_table('third_party_keys')
+    
+    # Create the new keys table
+    op.create_table('keys',
+        sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+        sa.Column('name', sa.String(), nullable=False),
+        sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=True),
+        sa.Column('encrypted_key', sa.String(), nullable=False),
+        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+        sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_keys_owner_id', 'keys', ['owner_id'], unique=False)
+    op.create_index('idx_keys_service', 'keys', ['name'], unique=False)
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # Drop the keys table
+    op.drop_index('idx_keys_service', table_name='keys')
+    op.drop_index('idx_keys_owner_id', table_name='keys')
+    op.drop_table('keys')
+    
+    # Recreate the third_party_keys table
+    op.create_table('third_party_keys',
+        sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+        sa.Column('service', sa.String(), nullable=False),
+        sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=True),
+        sa.Column('encrypted_key', sa.String(), nullable=False),
+        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+        sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_keys_owner_id', 'third_party_keys', ['owner_id'], unique=False)
+    op.create_index('idx_keys_service', 'third_party_keys', ['service'], unique=False)

+ 107 - 0
flowsint-api/alembic/versions/a1b2c3d4e5f6_make_column_types_portable.py

@@ -0,0 +1,107 @@
+"""make column types portable (JSONB->JSON, ARRAY->JSON/TEXT)
+
+Revision ID: a1b2c3d4e5f6
+Revises: 8173aba964e7
+Create Date: 2026-02-07 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "a1b2c3d4e5f6"
+down_revision: Union[str, None] = "8173aba964e7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # 1. logs.content: JSONB -> JSON
+    op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSON USING content::text::json")
+
+    # 2. custom_types.schema: JSONB -> JSON
+    op.execute(
+        'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSON USING "schema"::text::json'
+    )
+
+    # 3. flows.category: ARRAY(Text) -> JSON
+    op.execute(
+        """
+        ALTER TABLE flows ALTER COLUMN category TYPE JSON
+        USING CASE
+            WHEN category IS NULL THEN NULL
+            ELSE array_to_json(category)
+        END
+        """
+    )
+
+    # 4. investigation_user_roles.roles: ARRAY(role_enum) -> TEXT (JSON string)
+    # Convert PostgreSQL enum array like {OWNER,EDITOR} to JSON string like '["owner","editor"]'
+    op.execute(
+        """
+        ALTER TABLE investigation_user_roles ALTER COLUMN roles TYPE TEXT
+        USING CASE
+            WHEN roles IS NULL THEN '[]'
+            ELSE lower(array_to_json(roles::text[])::text)
+        END
+        """
+    )
+
+    # Remove the server_default that used PostgreSQL array literal '{}'
+    op.alter_column("investigation_user_roles", "roles", server_default=None)
+
+    # Drop the role_enum type (no longer needed)
+    op.execute("DROP TYPE IF EXISTS role_enum")
+
+
+def downgrade() -> None:
+    # Recreate the role_enum type
+    op.execute("CREATE TYPE role_enum AS ENUM ('OWNER', 'EDITOR', 'VIEWER')")
+
+    # 4. investigation_user_roles.roles: TEXT -> ARRAY(role_enum)
+    # Use a temp column to avoid subquery restriction in USING
+    op.execute("ALTER TABLE investigation_user_roles ADD COLUMN roles_tmp role_enum[]")
+    op.execute(
+        """
+        UPDATE investigation_user_roles SET roles_tmp = CASE
+            WHEN roles IS NULL OR roles = '[]' THEN '{}'::role_enum[]
+            ELSE (
+                SELECT array_agg(upper(elem)::role_enum)
+                FROM json_array_elements_text(roles::json) AS elem
+            )
+        END
+        """
+    )
+    op.execute("ALTER TABLE investigation_user_roles DROP COLUMN roles")
+    op.execute("ALTER TABLE investigation_user_roles RENAME COLUMN roles_tmp TO roles")
+    op.alter_column(
+        "investigation_user_roles", "roles", server_default=sa.text("'{}'")
+    )
+
+    # 3. flows.category: JSON -> ARRAY(Text)
+    # Use a temp column to avoid subquery restriction in USING
+    op.execute("ALTER TABLE flows ADD COLUMN category_tmp TEXT[]")
+    op.execute(
+        """
+        UPDATE flows SET category_tmp = CASE
+            WHEN category IS NULL THEN NULL
+            ELSE (
+                SELECT array_agg(elem::text)
+                FROM json_array_elements_text(category::json) AS elem
+            )
+        END
+        """
+    )
+    op.execute("ALTER TABLE flows DROP COLUMN category")
+    op.execute("ALTER TABLE flows RENAME COLUMN category_tmp TO category")
+
+    # 2. custom_types.schema: JSON -> JSONB
+    op.execute(
+        'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSONB USING "schema"::jsonb'
+    )
+
+    # 1. logs.content: JSON -> JSONB
+    op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSONB USING content::jsonb")

+ 59 - 0
flowsint-api/alembic/versions/a1f2b3c4d5e6_backfill_owner_roles.py

@@ -0,0 +1,59 @@
+"""backfill owner roles for existing investigations
+
+Revision ID: a1f2b3c4d5e6
+Revises: bac5764d4496
+Create Date: 2026-04-11 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+from uuid import uuid4
+
+from alembic import op
+import sqlalchemy as sa
+import json
+
+# revision identifiers, used by Alembic.
+revision: str = "a1f2b3c4d5e6"
+down_revision: Union[str, None] = "bac5764d4496"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Insert OWNER role entry for every investigation that lacks one."""
+    conn = op.get_bind()
+
+    # Find investigations with no entry in investigation_user_roles
+    rows = conn.execute(
+        sa.text(
+            """
+            SELECT i.id, i.owner_id
+            FROM investigations i
+            LEFT JOIN investigation_user_roles r
+                ON r.investigation_id = i.id AND r.user_id = i.owner_id
+            WHERE r.id IS NULL
+              AND i.owner_id IS NOT NULL
+            """
+        )
+    ).fetchall()
+
+    for inv_id, owner_id in rows:
+        conn.execute(
+            sa.text(
+                """
+                INSERT INTO investigation_user_roles (id, user_id, investigation_id, roles)
+                VALUES (:id, :user_id, :investigation_id, :roles)
+                """
+            ),
+            {
+                "id": str(uuid4()),
+                "user_id": str(owner_id),
+                "investigation_id": str(inv_id),
+                "roles": json.dumps(["owner"]),
+            },
+        )
+
+
+def downgrade() -> None:
+    """No-op: we don't remove the backfilled rows."""
+    pass

+ 32 - 0
flowsint-api/alembic/versions/afdaf9aa539c_add_passive_delete.py

@@ -0,0 +1,32 @@
+"""add passive_delete
+
+Revision ID: afdaf9aa539c
+Revises: 6be831edfda7
+Create Date: 2025-09-17 22:46:21.139127
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'afdaf9aa539c'
+down_revision: Union[str, None] = '6be831edfda7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 28 - 0
flowsint-api/alembic/versions/b2c3d4e5f6a7_add_description_to_enricher_templates.py

@@ -0,0 +1,28 @@
+"""add description to enricher_templates
+
+Revision ID: b2c3d4e5f6a7
+Revises: a1b2c3d4e5f6
+Create Date: 2025-01-31
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'b2c3d4e5f6a7'
+down_revision: Union[str, None] = 'a1b2c3d4e5f6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Add description column to enricher_templates table."""
+    op.add_column('enricher_templates', sa.Column('description', sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+    """Remove description column from enricher_templates table."""
+    op.drop_column('enricher_templates', 'description')

+ 36 - 0
flowsint-api/alembic/versions/ba3d00e11612_add_cascade_delete_to_scans_and_logs.py

@@ -0,0 +1,36 @@
+"""add cascade delete to scans and logs
+
+Revision ID: ba3d00e11612
+Revises: 2da47dbd4a52
+Create Date: 2025-06-08 22:11:30.281117
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'ba3d00e11612'
+down_revision: Union[str, None] = '2da47dbd4a52'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column('scans', 'sketch_id',
+               existing_type=sa.UUID(),
+               nullable=True)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column('scans', 'sketch_id',
+               existing_type=sa.UUID(),
+               nullable=False)
+    # ### end Alembic commands ###

+ 42 - 0
flowsint-api/alembic/versions/bac5764d4496_add_icon_and_color_to_custom_types.py

@@ -0,0 +1,42 @@
+"""add icon and color to custom types
+
+Revision ID: bac5764d4496
+Revises: f5fae279ec04
+Create Date: 2026-02-14 12:10:26.415916
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'bac5764d4496'
+down_revision: Union[str, None] = 'f5fae279ec04'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('custom_types', sa.Column('icon', sa.String(), nullable=True))
+    op.add_column('custom_types', sa.Column('color', sa.String(), nullable=True))
+    op.alter_column('enricher_templates', 'content',
+               existing_type=postgresql.JSONB(astext_type=sa.Text()),
+               type_=sa.JSON(),
+               existing_nullable=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column('enricher_templates', 'content',
+               existing_type=sa.JSON(),
+               type_=postgresql.JSONB(astext_type=sa.Text()),
+               existing_nullable=False)
+    op.drop_column('custom_types', 'color')
+    op.drop_column('custom_types', 'icon')
+    # ### end Alembic commands ###

+ 33 - 0
flowsint-api/alembic/versions/c82bf6af92e5_add_investigation_roles_permissions.py

@@ -0,0 +1,33 @@
+"""add investigation roles permissions
+
+
+Revision ID: c82bf6af92e5
+Revises: d39941278a91
+Create Date: 2025-09-17 21:55:56.756716
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'c82bf6af92e5'
+down_revision: Union[str, None] = 'd39941278a91'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 80 - 0
flowsint-api/alembic/versions/c9d8e7f6a5b4_add_enricher_templates_table.py

@@ -0,0 +1,80 @@
+"""add enricher_templates table
+
+Revision ID: a1b2c3d4e5f6
+Revises: 8173aba964e7
+Create Date: 2025-01-31
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "c9d8e7f6a5b4"
+down_revision: Union[str, None] = "a1b2c3d4e5f6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    op.create_table(
+        "enricher_templates",
+        sa.Column("id", sa.UUID(), nullable=False),
+        sa.Column("name", sa.Text(), nullable=False),
+        sa.Column("category", sa.Text(), nullable=False),
+        sa.Column("version", sa.Float(), nullable=False, server_default="1.0"),
+        sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+        sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
+        sa.Column("owner_id", sa.UUID(), nullable=False),
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=True,
+        ),
+        sa.Column(
+            "updated_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=True,
+        ),
+        sa.ForeignKeyConstraint(
+            ["owner_id"], ["profiles.id"], onupdate="CASCADE", ondelete="CASCADE"
+        ),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index(
+        "idx_enricher_templates_owner_id",
+        "enricher_templates",
+        ["owner_id"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_enricher_templates_name", "enricher_templates", ["name"], unique=False
+    )
+    op.create_index(
+        "idx_enricher_templates_category",
+        "enricher_templates",
+        ["category"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_enricher_templates_is_public",
+        "enricher_templates",
+        ["is_public"],
+        unique=False,
+    )
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    op.drop_index("idx_enricher_templates_is_public", table_name="enricher_templates")
+    op.drop_index("idx_enricher_templates_category", table_name="enricher_templates")
+    op.drop_index("idx_enricher_templates_name", table_name="enricher_templates")
+    op.drop_index("idx_enricher_templates_owner_id", table_name="enricher_templates")
+    op.drop_table("enricher_templates")

+ 32 - 0
flowsint-api/alembic/versions/d0a8e5b5a7b9_add_relationship_between_investigations_.py

@@ -0,0 +1,32 @@
+"""Add relationship between investigations and sketches
+
+Revision ID: d0a8e5b5a7b9
+Revises: 40ece72583b7
+Create Date: 2025-05-19 23:57:29.084171
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'd0a8e5b5a7b9'
+down_revision: Union[str, None] = '40ece72583b7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 32 - 0
flowsint-api/alembic/versions/d39941278a91_init.py

@@ -0,0 +1,32 @@
+"""init
+
+Revision ID: d39941278a91
+Revises: 6e49acfb3816
+Create Date: 2025-09-17 21:52:57.142634
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'd39941278a91'
+down_revision: Union[str, None] = '6e49acfb3816'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    pass
+    # ### end Alembic commands ###

+ 45 - 0
flowsint-api/alembic/versions/e403a4152f6b_add_third_party_keys_table.py

@@ -0,0 +1,45 @@
+"""add_third_party_keys_table
+
+Revision ID: e403a4152f6b
+Revises: 6dfa83113ad7
+Create Date: 2025-06-22 22:43:43.440473
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'e403a4152f6b'
+down_revision: Union[str, None] = '6dfa83113ad7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('third_party_keys',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('service', sa.String(), nullable=False),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.Column('encrypted_key', sa.String(), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id'),
+    if_not_exists=True
+    )
+    op.create_index('idx_keys_owner_id', 'third_party_keys', ['owner_id'], unique=False)
+    op.create_index('idx_keys_service', 'third_party_keys', ['service'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('idx_keys_service', table_name='third_party_keys')
+    op.drop_index('idx_keys_owner_id', table_name='third_party_keys')
+    op.drop_table('third_party_keys')
+    # ### end Alembic commands ###

+ 28 - 0
flowsint-api/alembic/versions/f5fae279ec04_merge_portable_column_types_and_.py

@@ -0,0 +1,28 @@
+"""merge portable column types and enricher templates
+
+Revision ID: f5fae279ec04
+Revises: b2c3d4e5f6a7, c9d8e7f6a5b4
+Create Date: 2026-02-07 18:00:18.801891
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'f5fae279ec04'
+down_revision: Union[str, None] = ('b2c3d4e5f6a7', 'c9d8e7f6a5b4')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    pass
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    pass

+ 48 - 0
flowsint-api/alembic/versions/fa0ab51b2f64_add_analysis_model_and_investigation_.py

@@ -0,0 +1,48 @@
+"""add analysis model and investigation relationship
+
+Revision ID: fa0ab51b2f64
+Revises: 76f5436251e3
+Create Date: 2025-06-04 11:15:03.736931
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'fa0ab51b2f64'
+down_revision: Union[str, None] = '76f5436251e3'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('analyses',
+    sa.Column('id', sa.UUID(), nullable=False),
+    sa.Column('title', sa.Text(), nullable=False),
+    sa.Column('description', sa.Text(), nullable=True),
+    sa.Column('content', sa.JSON(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('last_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
+    sa.Column('owner_id', sa.UUID(), nullable=True),
+    sa.Column('investigation_id', sa.UUID(), nullable=True),
+    sa.ForeignKeyConstraint(['investigation_id'], ['investigations.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('idx_analyses_investigation_id', 'analyses', ['investigation_id'], unique=False)
+    op.create_index('idx_analyses_owner_id', 'analyses', ['owner_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index('idx_analyses_owner_id', table_name='analyses')
+    op.drop_index('idx_analyses_investigation_id', table_name='analyses')
+    op.drop_table('analyses')
+    # ### end Alembic commands ###

+ 34 - 0
flowsint-api/alembic/versions/faceebd6a580_remove_scan_id_of_logs.py

@@ -0,0 +1,34 @@
+"""remove scan_id of logs
+
+Revision ID: faceebd6a580
+Revises: fa0ab51b2f64
+Create Date: 2025-06-07 20:03:48.966194
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'faceebd6a580'
+down_revision: Union[str, None] = 'fa0ab51b2f64'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint('logs_scan_id_fkey', 'logs', type_='foreignkey')
+    op.drop_column('logs', 'scan_id')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('logs', sa.Column('scan_id', sa.UUID(), autoincrement=False, nullable=True))
+    op.create_foreign_key('logs_scan_id_fkey', 'logs', 'scans', ['scan_id'], ['id'], ondelete='CASCADE')
+    # ### end Alembic commands ###

+ 0 - 0
flowsint-api/app/__init__.py


+ 0 - 0
flowsint-api/app/api/__init__.py


+ 70 - 0
flowsint-api/app/api/deps.py

@@ -0,0 +1,70 @@
+from fastapi import Depends, HTTPException, status, Request
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from sqlalchemy.orm import Session
+from flowsint_core.core.auth import ALGORITHM, AUTH_SECRET
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.models import Profile
+from typing import Optional
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+
+def get_current_user(
+    token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
+) -> Profile:
+    credentials_exception = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Could not validate credentials",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+    try:
+        payload = jwt.decode(token, AUTH_SECRET, algorithms=[ALGORITHM])
+        email: str = payload.get("sub")
+        if email is None:
+            raise credentials_exception
+    except JWTError:
+        raise credentials_exception
+    user = db.query(Profile).filter(Profile.email == email).first()
+    if user is None:
+        raise credentials_exception
+    return user
+
+
+def get_current_user_sse(
+    request: Request, db: Session = Depends(get_db)
+) -> Profile:
+    """
+    Alternative authentication for SSE endpoints that accepts token via query parameter.
+    EventSource API doesn't support custom headers, so we need to pass the token in the URL.
+    """
+    credentials_exception = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Could not validate credentials",
+    )
+
+    # Try to get token from query parameter
+    token: Optional[str] = request.query_params.get("token")
+
+    # Fallback to Authorization header if query param not present
+    if not token:
+        auth_header = request.headers.get("Authorization")
+        if auth_header and auth_header.startswith("Bearer "):
+            token = auth_header.replace("Bearer ", "")
+
+    if not token:
+        raise credentials_exception
+
+    try:
+        payload = jwt.decode(token, AUTH_SECRET, algorithms=[ALGORITHM])
+        email: str = payload.get("sub")
+        if email is None:
+            raise credentials_exception
+    except JWTError:
+        raise credentials_exception
+
+    user = db.query(Profile).filter(Profile.email == email).first()
+    if user is None:
+        raise credentials_exception
+
+    return user

+ 0 - 0
flowsint-api/app/api/routes/__init__.py


+ 113 - 0
flowsint-api/app/api/routes/analysis.py

@@ -0,0 +1,113 @@
+from uuid import UUID
+from fastapi import APIRouter, HTTPException, Depends, status
+from typing import List
+from sqlalchemy.orm import Session
+
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.models import Profile
+from flowsint_core.core.services import (
+    create_analysis_service,
+    NotFoundError,
+    PermissionDeniedError,
+)
+from app.api.deps import get_current_user
+from app.api.schemas.analysis import AnalysisRead, AnalysisCreate, AnalysisUpdate
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[AnalysisRead])
+def get_analyses(
+    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
+):
+    """Get all analyses accessible to the current user."""
+    service = create_analysis_service(db)
+    return service.get_accessible_analyses(current_user.id)
+
+
+@router.post(
+    "/create", response_model=AnalysisRead, status_code=status.HTTP_201_CREATED
+)
+def create_analysis(
+    payload: AnalysisCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_analysis_service(db)
+    try:
+        return service.create(
+            title=payload.title,
+            description=payload.description,
+            content=payload.content,
+            investigation_id=payload.investigation_id,
+            owner_id=current_user.id,
+        )
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.get("/{analysis_id}", response_model=AnalysisRead)
+def get_analysis_by_id(
+    analysis_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_analysis_service(db)
+    try:
+        return service.get_by_id(analysis_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Analysis not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.get("/investigation/{investigation_id}", response_model=List[AnalysisRead])
+def get_analyses_by_investigation(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_analysis_service(db)
+    try:
+        return service.get_by_investigation(investigation_id, current_user.id)
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.put("/{analysis_id}", response_model=AnalysisRead)
+def update_analysis(
+    analysis_id: UUID,
+    payload: AnalysisUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_analysis_service(db)
+    try:
+        return service.update(
+            analysis_id=analysis_id,
+            user_id=current_user.id,
+            title=payload.title,
+            description=payload.description,
+            content=payload.content,
+            investigation_id=payload.investigation_id,
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Analysis not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.delete("/{analysis_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_analysis(
+    analysis_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_analysis_service(db)
+    try:
+        service.delete(analysis_id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Analysis not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")

+ 81 - 0
flowsint-api/app/api/routes/auth.py

@@ -0,0 +1,81 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.security import OAuth2PasswordRequestForm
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import SQLAlchemyError
+from typing import List
+
+from flowsint_core.core.services import (
+    create_auth_service,
+    AuthenticationError,
+    ConflictError,
+    DatabaseError,
+)
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from app.api.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate
+from app.api.deps import get_current_user
+
+router = APIRouter()
+
+
+@router.post("/token")
+def login_for_access_token(
+    form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
+):
+    service = create_auth_service(db)
+    try:
+        return service.authenticate(form_data.username, form_data.password)
+    except AuthenticationError:
+        raise HTTPException(status_code=400, detail="Incorrect email or password")
+    except (DatabaseError, SQLAlchemyError) as e:
+        print(f"[ERROR] DB error during login: {e}")
+        raise HTTPException(status_code=500, detail="Internal server error")
+
+
+@router.post("/register", status_code=201)
+def register(user: ProfileCreate, db: Session = Depends(get_db)):
+    service = create_auth_service(db)
+    try:
+        return service.register(user.email, user.password)
+    except ConflictError:
+        raise HTTPException(status_code=400, detail="Email already registered")
+    except (DatabaseError, SQLAlchemyError) as e:
+        print(f"[ERROR] DB error during registration: {e}")
+        raise HTTPException(status_code=500, detail="Internal server error")
+
+
+@router.get("/me", response_model=ProfileRead)
+def get_me(current_user: Profile = Depends(get_current_user)):
+    return current_user
+
+
+@router.put("/me", response_model=ProfileRead)
+def update_me(
+    payload: ProfileUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    for key, value in payload.model_dump(exclude_unset=True).items():
+        setattr(current_user, key, value)
+    db.commit()
+    db.refresh(current_user)
+    return current_user
+
+
+@router.get("/users/search", response_model=List[ProfileRead])
+def search_users(
+    q: str = Query(..., min_length=1),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Search users by email prefix for the share dialog autocomplete."""
+    results = (
+        db.query(Profile)
+        .filter(
+            Profile.email.ilike(f"{q}%"),
+            Profile.id != current_user.id,
+        )
+        .limit(5)
+        .all()
+    )
+    return results

+ 114 - 0
flowsint-api/app/api/routes/chat.py

@@ -0,0 +1,114 @@
+from typing import Dict, List, Optional
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import StreamingResponse
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    NotFoundError,
+    create_chat_service,
+)
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.chat import ChatCreate, ChatRead
+
+router = APIRouter()
+
+
+class ChatRequest(BaseModel):
+    prompt: str
+    context: Optional[List[str]] = None
+
+
+@router.get("", response_model=List[ChatRead])
+def get_chats(
+    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
+):
+    service = create_chat_service(db)
+    return service.get_chats_for_user(current_user.id)
+
+
+@router.get("/investigation/{investigation_id}", response_model=List[ChatRead])
+def get_chats_by_investigation(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_chat_service(db)
+    return service.get_by_investigation(investigation_id, current_user.id)
+
+
+@router.post("/stream/{chat_id}")
+async def stream_chat(
+    chat_id: UUID,
+    payload: ChatRequest,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_chat_service(db)
+
+    try:
+        chat = service.get_by_id(chat_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Chat not found")
+
+    service.add_user_message(chat_id, current_user.id, payload.prompt, payload.context)
+
+    ai_context = service.prepare_ai_context(chat, payload.prompt, payload.context)
+    llm_messages = service.build_llm_messages(ai_context)
+
+    try:
+        provider = service.get_llm_provider(current_user.id)
+    except ValueError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+    return StreamingResponse(
+        service.stream_response(chat_id, llm_messages, provider),
+        media_type="text/event-stream",
+        headers={"x-vercel-ai-ui-message-stream": "v1"},
+    )
+
+
+@router.post("/create", response_model=ChatRead, status_code=status.HTTP_201_CREATED)
+def create_chat(
+    payload: ChatCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_chat_service(db)
+    return service.create(
+        title=payload.title,
+        description=payload.description,
+        investigation_id=payload.investigation_id,
+        owner_id=current_user.id,
+    )
+
+
+@router.get("/{chat_id}", response_model=ChatRead)
+def get_chat_by_id(
+    chat_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_chat_service(db)
+    try:
+        return service.get_by_id(chat_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Chat not found")
+
+
+@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_chat(
+    chat_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_chat_service(db)
+    try:
+        service.delete(chat_id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Chat not found")

+ 166 - 0
flowsint-api/app/api/routes/custom_types.py

@@ -0,0 +1,166 @@
+"""API routes for custom types management."""
+
+from typing import List
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    ConflictError,
+    NotFoundError,
+    ValidationError,
+    create_custom_type_service,
+)
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.custom_type import (
+    CustomTypeCreate,
+    CustomTypeRead,
+    CustomTypeUpdate,
+    CustomTypeValidatePayload,
+    CustomTypeValidateResponse,
+)
+from app.utils.custom_types import (
+    calculate_schema_checksum,
+    validate_json_schema,
+    validate_payload_against_schema,
+)
+
+router = APIRouter()
+
+
+@router.post("", response_model=CustomTypeRead, status_code=status.HTTP_201_CREATED)
+def create_custom_type(
+    custom_type: CustomTypeCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Create a new custom type."""
+    service = create_custom_type_service(db)
+    try:
+        return service.create(
+            name=custom_type.name,
+            json_schema=custom_type.json_schema,
+            user_id=current_user.id,
+            description=custom_type.description,
+            status=custom_type.status,
+            validate_schema_func=validate_json_schema,
+            calculate_checksum_func=calculate_schema_checksum,
+        )
+    except ValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except ConflictError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("", response_model=List[CustomTypeRead])
+def list_custom_types(
+    status: str = None,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """List all custom types for the current user."""
+    service = create_custom_type_service(db)
+    try:
+        return service.list_custom_types(current_user.id, status)
+    except ValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/{id}", response_model=CustomTypeRead)
+def get_custom_type(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get a specific custom type by ID."""
+    service = create_custom_type_service(db)
+    try:
+        return service.get_by_id(id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Custom type not found")
+
+
+@router.get("/{id}/schema")
+def get_custom_type_schema(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get the raw JSON Schema for a custom type."""
+    service = create_custom_type_service(db)
+    try:
+        return service.get_schema(id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Custom type not found")
+
+
+@router.put("/{id}", response_model=CustomTypeRead)
+def update_custom_type(
+    id: UUID,
+    update_data: CustomTypeUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Update a custom type."""
+    service = create_custom_type_service(db)
+    try:
+        return service.update(
+            custom_type_id=id,
+            user_id=current_user.id,
+            name=update_data.name,
+            icon=update_data.icon,
+            color=update_data.color,
+            json_schema=update_data.json_schema,
+            description=update_data.description,
+            status=update_data.status,
+            validate_schema_func=validate_json_schema,
+            calculate_checksum_func=calculate_schema_checksum,
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Custom type not found")
+    except ValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except ConflictError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_custom_type(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Delete a custom type."""
+    service = create_custom_type_service(db)
+    try:
+        service.delete(id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Custom type not found")
+
+
+@router.post("/{id}/validate", response_model=CustomTypeValidateResponse)
+def validate_payload(
+    id: UUID,
+    payload_data: CustomTypeValidatePayload,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Validate a payload against a custom type's schema."""
+    service = create_custom_type_service(db)
+    try:
+        is_valid, errors = service.validate_payload(
+            id,
+            current_user.id,
+            payload_data.payload,
+            validate_payload_func=validate_payload_against_schema,
+        )
+        return CustomTypeValidateResponse(
+            valid=is_valid,
+            errors=errors if not is_valid else None,
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Custom type not found")

+ 206 - 0
flowsint-api/app/api/routes/enricher_templates.py

@@ -0,0 +1,206 @@
+"""API routes for enricher templates management."""
+
+from typing import List
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    ConflictError,
+    NotFoundError,
+    ValidationError,
+    create_enricher_template_service,
+    create_template_generator_service,
+)
+from flowsint_core.core.template_enricher import TemplateEnricher
+from flowsint_core.core.vault import Vault
+from flowsint_core.templates.types import Template
+from sqlalchemy.orm import Session
+
+from flowsint_types.registry import get_type as get_type_from_registry, load_all_types
+
+from app.api.deps import get_current_user
+from app.api.schemas.enricher_template import (
+    EnricherTemplateCreate,
+    EnricherTemplateGenerateRequest,
+    EnricherTemplateGenerateResponse,
+    EnricherTemplateList,
+    EnricherTemplateRead,
+    EnricherTemplateTestRequest,
+    EnricherTemplateTestResponse,
+    EnricherTemplateUpdate,
+)
+
+router = APIRouter()
+
+
+@router.post(
+    "", response_model=EnricherTemplateRead, status_code=status.HTTP_201_CREATED
+)
+def create_template(
+    template: EnricherTemplateCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Create a new enricher template."""
+    content = template.content
+    name = content.get("name", template.name)
+    description = content.get("description", template.description)
+    category = content.get("category", template.category)
+    version = float(content.get("version", template.version))
+
+    service = create_enricher_template_service(db)
+    try:
+        return service.create_template(
+            name=name,
+            description=description,
+            category=category,
+            version=version,
+            content=content,
+            is_public=template.is_public,
+            owner_id=current_user.id,
+        )
+    except ConflictError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("", response_model=List[EnricherTemplateList])
+def list_templates(
+    category: str = Query(None, description="Filter by category"),
+    include_public: bool = Query(
+        True, description="Include public templates from other users"
+    ),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """List enricher templates."""
+    service = create_enricher_template_service(db)
+    return service.list_templates(current_user.id, category, include_public)
+
+
+@router.post("/generate", response_model=EnricherTemplateGenerateResponse)
+async def generate_template(
+    request: EnricherTemplateGenerateRequest,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Generate an enricher template from a free-text description using AI."""
+    load_all_types()
+
+    input_schema = None
+    output_schema = None
+
+    if request.input_type:
+        input_cls = get_type_from_registry(request.input_type, case_sensitive=True)
+        if input_cls is None:
+            raise HTTPException(
+                status_code=422,
+                detail=f"Unknown input type: '{request.input_type}'",
+            )
+        input_schema = input_cls.model_json_schema()
+
+    if request.output_type:
+        output_cls = get_type_from_registry(request.output_type, case_sensitive=True)
+        if output_cls is None:
+            raise HTTPException(
+                status_code=422,
+                detail=f"Unknown output type: '{request.output_type}'",
+            )
+        output_schema = output_cls.model_json_schema()
+
+    service = create_template_generator_service(db)
+    try:
+        yaml_content = await service.generate(
+            prompt=request.prompt,
+            owner_id=current_user.id,
+            input_type=request.input_type,
+            input_schema=input_schema,
+            output_type=request.output_type,
+            output_schema=output_schema,
+        )
+    except ValidationError as e:
+        raise HTTPException(status_code=422, detail=str(e))
+    return EnricherTemplateGenerateResponse(yaml_content=yaml_content)
+
+
+@router.post("/{template_id}/test", response_model=EnricherTemplateTestResponse)
+async def test_template(
+    template_id: UUID,
+    test_request: EnricherTemplateTestRequest,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Test an enricher template with a sample input value."""
+    service = create_enricher_template_service(db)
+    try:
+        db_template = service.get_template(template_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    try:
+        content = db_template.content
+        template = Template(**content)
+        vault = Vault(db=db, owner_id=current_user.id)
+        enricher = TemplateEnricher(
+            sketch_id="123", scan_id="123", template=template, vault=vault
+        )
+        await enricher.async_init()
+        pre = enricher.preprocess([test_request.input_value])
+        results = await enricher.scan(pre)
+        data = {"results": results, "raw_results": enricher.get_raw_response()}
+        return EnricherTemplateTestResponse(
+            success=True, data=data, status_code=200, url=template.request.url
+        )
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"An error occured : {e}")
+
+
+@router.get("/{template_id}", response_model=EnricherTemplateRead)
+def get_template(
+    template_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get a specific enricher template by ID."""
+    service = create_enricher_template_service(db)
+    try:
+        return service.get_template(template_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+
+@router.put("/{template_id}", response_model=EnricherTemplateRead)
+def update_template(
+    template_id: UUID,
+    update_data: EnricherTemplateUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Update an enricher template. Only the owner can update."""
+    service = create_enricher_template_service(db)
+    try:
+        return service.update_template(
+            template_id=template_id,
+            owner_id=current_user.id,
+            update_data=update_data.model_dump(exclude_unset=True),
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Template not found")
+    except ConflictError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_template(
+    template_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Delete an enricher template. Only the owner can delete."""
+    service = create_enricher_template_service(db)
+    try:
+        service.delete_template(template_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Template not found")
+    return None

+ 96 - 0
flowsint-api/app/api/routes/enrichers.py

@@ -0,0 +1,96 @@
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from flowsint_core.core.celery import celery
+from flowsint_core.core.graph import create_graph_service
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    create_enricher_service,
+    create_enricher_template_service,
+)
+from flowsint_core.core.services.type_registry_service import create_type_registry_service
+from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+
+load_all_enrichers()
+
+
+class launchEnricherPayload(BaseModel):
+    node_ids: List[str]
+    sketch_id: str
+
+
+router = APIRouter()
+
+
+@router.get("")
+def get_enrichers(
+    category: Optional[str] = Query(None),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get all enrichers, optionally filtered by category."""
+    enricher_service = create_enricher_service(db)
+    return enricher_service.get_all_enrichers(
+        category, current_user.id, ENRICHER_REGISTRY
+    )
+
+
+@router.post("/{enricher_name}/launch")
+async def launch_enricher(
+    enricher_name: str,
+    payload: launchEnricherPayload,
+    current_user: Profile = Depends(get_current_user),
+    db: Session = Depends(get_db),
+):
+    try:
+        # Retrieve nodes from Neo4J by their element IDs
+        type_registry = create_type_registry_service(db)
+        resolver = type_registry.build_type_resolver(current_user.id)
+        graph_service = create_graph_service(sketch_id=payload.sketch_id, type_resolver=resolver)
+        entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
+
+        # Send deserialized nodes
+        entities = [
+            entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
+        ]
+        if not entities:
+            raise HTTPException(
+                status_code=404, detail="No entities found with provided IDs"
+            )
+
+        is_template = False
+        enricher_in_registry = ENRICHER_REGISTRY.enricher_exists(enricher_name)
+        if not enricher_in_registry:
+            template_service = create_enricher_template_service(db)
+            template = template_service.find_by_name(enricher_name, current_user.id)
+            if not template:
+                raise HTTPException(
+                    status_code=404,
+                    detail=f"Enricher '{enricher_name}' not found",
+                )
+            is_template = True
+
+        task_name = "run_template_enricher" if is_template else "run_enricher"
+        task = celery.send_task(
+            task_name,
+            args=[
+                enricher_name,
+                entities,
+                payload.sketch_id,
+                str(current_user.id),
+            ],
+        )
+        return {"id": task.id}
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        print(e)
+        raise HTTPException(
+            status_code=500, detail=f"Error launching enricher: {str(e)}"
+        )

+ 208 - 0
flowsint-api/app/api/routes/events.py

@@ -0,0 +1,208 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+from sse_starlette.sse import EventSourceResponse
+import json
+import asyncio
+from datetime import datetime
+
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.events import event_emitter
+from flowsint_core.core.models import Profile
+from flowsint_core.core.services import (
+    create_log_service,
+    NotFoundError,
+    PermissionDeniedError,
+    DatabaseError,
+)
+from app.api.deps import get_current_user, get_current_user_sse
+
+router = APIRouter()
+
+
+@router.get("/sketch/{sketch_id}/logs")
+def get_logs_by_sketch(
+    sketch_id: str,
+    limit: int = 100,
+    since: datetime | None = None,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get historical logs for a specific sketch with optional filtering."""
+    service = create_log_service(db)
+    try:
+        return service.get_logs_by_sketch(sketch_id, current_user.id, limit, since)
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.get("/sketch/{sketch_id}/stream")
+async def stream_events(
+    request: Request,
+    sketch_id: str,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user_sse),
+):
+    """Stream events for a specific sketch in real-time."""
+    service = create_log_service(db)
+    try:
+        # Verify permission
+        service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+    async def event_generator():
+        channel = sketch_id
+        await event_emitter.subscribe(channel)
+        try:
+            yield 'data: {"event": "connected", "data": "Connected to log stream"}\n\n'
+            while True:
+                if await request.is_disconnected():
+                    break
+
+                data = await event_emitter.get_message(channel)
+                if data is None:
+                    await asyncio.sleep(0.1)
+                    continue
+
+                if isinstance(data, dict) and data.get("type") == "enricher_complete":
+                    yield json.dumps({"event": "enricher_complete", "data": data})
+                else:
+                    yield json.dumps({"event": "log", "data": data})
+                await asyncio.sleep(0.1)
+
+        except asyncio.CancelledError:
+            print(f"[EventEmitter] Client disconnected from sketch_id: {sketch_id}")
+        except Exception as e:
+            print(f"[EventEmitter] Error in stream_logs: {str(e)}")
+        finally:
+            await event_emitter.unsubscribe(channel)
+
+    return EventSourceResponse(
+        event_generator(),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+        },
+    )
+
+
+@router.delete("/sketch/{sketch_id}/logs")
+def delete_scan_logs(
+    sketch_id: str,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Delete all logs for a specific sketch."""
+    service = create_log_service(db)
+    try:
+        return service.delete_logs_by_sketch(sketch_id, current_user.id)
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/sketch/{sketch_id}/status/stream")
+async def stream_sketch_status(
+    request: Request,
+    sketch_id: str,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user_sse),
+):
+    """Stream COMPLETED events for a specific sketch (for graph refresh)."""
+    service = create_log_service(db)
+    try:
+        service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+    async def status_generator():
+        channel = f"{sketch_id}_status"
+        await event_emitter.subscribe(channel)
+        try:
+            yield json.dumps({"event": "connected", "data": "Connected to status stream"})
+
+            while True:
+                if await request.is_disconnected():
+                    break
+
+                data = await event_emitter.get_message(channel)
+                if data is None:
+                    await asyncio.sleep(0.1)
+                    continue
+
+                yield json.dumps({"event": "status", "data": data})
+                await asyncio.sleep(0.1)
+
+        except asyncio.CancelledError:
+            print(f"[EventEmitter] Client disconnected from status stream for sketch_id: {sketch_id}")
+        except Exception as e:
+            print(f"[EventEmitter] Error in stream_sketch_status: {str(e)}")
+        finally:
+            await event_emitter.unsubscribe(channel)
+
+    return EventSourceResponse(
+        status_generator(),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+        },
+    )
+
+
+@router.get("/status/scan/{scan_id}/stream")
+async def stream_status(
+    request: Request,
+    scan_id: str,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user_sse),
+):
+    """Stream status updates for a specific scan in real-time."""
+    service = create_log_service(db)
+    try:
+        service.get_scan_with_permission(scan_id, current_user.id)
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+    async def status_generator():
+        print("[EventEmitter] Start status generator")
+        await event_emitter.subscribe(f"scan_{scan_id}_status")
+        try:
+            yield 'data: {"event": "connected", "data": "Connected to status stream"}\n\n'
+
+            while True:
+                data = await event_emitter.get_message(f"scan_{scan_id}_status")
+                if data is None:
+                    await asyncio.sleep(0.1)
+                    continue
+                print(f"[EventEmitter] Received status data: {data}")
+                yield f"data: {data}\n\n"
+
+        except asyncio.CancelledError:
+            print(f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}")
+        finally:
+            await event_emitter.unsubscribe(f"scan_{scan_id}_status")
+
+    return EventSourceResponse(
+        status_generator(),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+        },
+    )

+ 523 - 0
flowsint-api/app/api/routes/flows.py

@@ -0,0 +1,523 @@
+from typing import Any, Dict, List, Optional
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from flowsint_core.core.celery import celery
+from flowsint_core.core.graph import create_graph_service
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    NotFoundError,
+    PermissionDeniedError,
+    create_flow_service,
+)
+from flowsint_core.core.services.type_registry_service import (
+    create_type_registry_service,
+)
+from flowsint_core.core.types import FlowBranch, FlowEdge, FlowNode, FlowStep
+from flowsint_core.utils import extract_input_schema_flow
+from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
+from flowsint_types import (
+    ASN,
+    CIDR,
+    CryptoNFT,
+    CryptoWallet,
+    CryptoWalletTransaction,
+    DNSRecord,
+    Domain,
+    Email,
+    Individual,
+    Ip,
+    Organization,
+    Phone,
+    Phrase,
+    Port,
+    SocialAccount,
+    Username,
+    Website,
+)
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.flow import FlowCreate, FlowRead, FlowUpdate
+
+load_all_enrichers()
+
+
+class FlowComputationRequest(BaseModel):
+    nodes: List[FlowNode]
+    edges: List[FlowEdge]
+    inputType: Optional[str] = None
+
+
+class FlowComputationResponse(BaseModel):
+    flowBranches: List[FlowBranch]
+    initialData: Any
+
+
+class StepSimulationRequest(BaseModel):
+    flowBranches: List[FlowBranch]
+    currentStepIndex: int
+
+
+class launchFlowPayload(BaseModel):
+    node_ids: List[str]
+    sketch_id: str
+
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[FlowRead])
+def get_flows(
+    category: Optional[str] = Query(None),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    return service.get_all_flows(category, current_user.id)
+
+
+@router.get("/raw_materials")
+async def get_material_list():
+    enrichers = ENRICHER_REGISTRY.list_by_categories()
+    enricher_categories = {
+        category: [
+            {
+                "class_name": enricher.get("class_name"),
+                "category": enricher.get("category"),
+                "name": enricher.get("name"),
+                "module": enricher.get("module"),
+                "documentation": enricher.get("documentation"),
+                "description": enricher.get("description"),
+                "inputs": enricher.get("inputs"),
+                "outputs": enricher.get("outputs"),
+                "type": "enricher",
+                "params": enricher.get("params"),
+                "params_schema": enricher.get("params_schema"),
+                "required_params": enricher.get("required_params"),
+                "icon": enricher.get("icon"),
+            }
+            for enricher in enricher_list
+        ]
+        for category, enricher_list in enrichers.items()
+    }
+
+    object_inputs = [
+        extract_input_schema_flow(Phrase),
+        extract_input_schema_flow(Organization),
+        extract_input_schema_flow(Individual),
+        extract_input_schema_flow(Domain),
+        extract_input_schema_flow(Website),
+        extract_input_schema_flow(Ip),
+        extract_input_schema_flow(DNSRecord),
+        extract_input_schema_flow(Port),
+        extract_input_schema_flow(Phone),
+        extract_input_schema_flow(ASN),
+        extract_input_schema_flow(CIDR),
+        extract_input_schema_flow(Username),
+        extract_input_schema_flow(SocialAccount),
+        extract_input_schema_flow(Email),
+        extract_input_schema_flow(CryptoWallet),
+        extract_input_schema_flow(CryptoWalletTransaction),
+        extract_input_schema_flow(CryptoNFT),
+    ]
+
+    flattened_enrichers = {"types": object_inputs}
+    flattened_enrichers.update(enricher_categories)
+
+    return {"items": flattened_enrichers}
+
+
+@router.get("/input_type/{input_type}")
+async def get_material_by_input_type(input_type: str):
+    enrichers = ENRICHER_REGISTRY.list_by_input_type(input_type)
+    return {"items": enrichers}
+
+
+@router.post("/create", response_model=FlowRead, status_code=status.HTTP_201_CREATED)
+def create_flow(
+    payload: FlowCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    return service.create(
+        name=payload.name,
+        description=payload.description,
+        category=payload.category,
+        flow_schema=payload.flow_schema,
+    )
+
+
+@router.get("/{flow_id}", response_model=FlowRead)
+def get_flow_by_id(
+    flow_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    try:
+        return service.get_by_id(flow_id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Flow not found")
+
+
+@router.put("/{flow_id}", response_model=FlowRead)
+def update_flow(
+    flow_id: UUID,
+    payload: FlowUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    try:
+        return service.update(flow_id, payload.model_dump(exclude_unset=True))
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Flow not found")
+
+
+@router.delete("/{flow_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_flow(
+    flow_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    try:
+        service.delete(flow_id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Flow not found")
+
+
+@router.post("/{flow_id}/launch")
+async def launch_flow(
+    flow_id: str,
+    payload: launchFlowPayload,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_flow_service(db)
+    try:
+        flow = service.get_by_id(UUID(flow_id))
+        service.get_sketch_for_launch(payload.sketch_id, current_user.id)
+
+        # Retrieve entities from Neo4J by their element IDs
+        type_registry = create_type_registry_service(db)
+        resolver = type_registry.build_type_resolver(current_user.id)
+        graph_service = create_graph_service(
+            sketch_id=payload.sketch_id, type_resolver=resolver
+        )
+        entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
+
+        # Compute flow branches
+        nodes = [FlowNode(**node) for node in flow.flow_schema["nodes"]]
+        edges = [FlowEdge(**edge) for edge in flow.flow_schema["edges"]]
+
+        entities = [
+            entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
+        ]
+
+        sample_value = (
+            entities[0].get("nodeLabel", "sample_value")
+            if len(entities)
+            else "sample_value"
+        )
+        flow_branches = compute_flow_branches(sample_value, nodes, edges)
+        serializable_branches = [branch.model_dump() for branch in flow_branches]
+
+        task = celery.send_task(
+            "run_flow",
+            args=[
+                serializable_branches,
+                entities,
+                payload.sketch_id,
+                str(current_user.id),
+            ],
+        )
+        return {"id": task.id}
+
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except Exception as e:
+        print(e)
+        raise HTTPException(status_code=500, detail=f"Error launching flow: {str(e)}")
+
+
+@router.post("/{flow_id}/compute", response_model=FlowComputationResponse)
+def compute_flows(
+    request: FlowComputationRequest, current_user: Profile = Depends(get_current_user)
+):
+    initial_data = generate_sample_data(request.inputType or "string")
+    flow_branches = compute_flow_branches(initial_data, request.nodes, request.edges)
+    return FlowComputationResponse(flowBranches=flow_branches, initialData=initial_data)
+
+
+def generate_sample_data(type_str: str) -> Any:
+    type_str = type_str.lower() if type_str else "string"
+    if type_str == "string":
+        return "sample_text"
+    elif type_str == "number":
+        return 42
+    elif type_str == "boolean":
+        return True
+    elif type_str == "array":
+        return [1, 2, 3]
+    elif type_str == "object":
+        return {"key": "value"}
+    elif type_str == "url":
+        return "https://example.com"
+    elif type_str == "email":
+        return "user@example.com"
+    elif type_str == "domain":
+        return "example.com"
+    elif type_str == "ip":
+        return "192.168.1.1"
+    else:
+        return f"sample_{type_str}"
+
+
+def compute_flow_branches(
+    initial_value: Any, nodes: List[FlowNode], edges: List[FlowEdge]
+) -> List[FlowBranch]:
+    """Computes flow branches based on nodes and edges with proper DFS traversal"""
+    input_nodes = [node for node in nodes if node.data.get("type") == "type"]
+
+    if not input_nodes:
+        return [
+            FlowBranch(
+                id="error",
+                name="Error",
+                steps=[
+                    FlowStep(
+                        nodeId="error",
+                        inputs={},
+                        params={},
+                        type="error",
+                        outputs={},
+                        status="error",
+                        branchId="error",
+                        depth=0,
+                    )
+                ],
+            )
+        ]
+
+    node_map = {node.id: node for node in nodes}
+    branches = []
+    branch_counter = 0
+    enricher_outputs = {}
+
+    def calculate_path_length(start_node: str, visited: set = None) -> int:
+        if visited is None:
+            visited = set()
+        if start_node in visited:
+            return float("inf")
+        visited.add(start_node)
+        out_edges = [edge for edge in edges if edge.source == start_node]
+        if not out_edges:
+            return 1
+        min_length = float("inf")
+        for edge in out_edges:
+            length = calculate_path_length(edge.target, visited.copy())
+            min_length = min(min_length, length)
+        return 1 + min_length
+
+    def get_outgoing_edges(node_id: str) -> List[FlowEdge]:
+        out_edges = [edge for edge in edges if edge.source == node_id]
+        return sorted(out_edges, key=lambda e: calculate_path_length(e.target))
+
+    def create_step(
+        node_id: str,
+        branch_id: str,
+        depth: int,
+        input_data: Dict[str, Any],
+        is_input_node: bool,
+        outputs: Dict[str, Any],
+        node_params: Optional[Dict[str, Any]] = None,
+    ) -> FlowStep:
+        return FlowStep(
+            nodeId=node_id,
+            params=node_params,
+            inputs={} if is_input_node else input_data,
+            outputs=outputs,
+            type="type" if is_input_node else "enricher",
+            status="pending",
+            branchId=branch_id,
+            depth=depth,
+        )
+
+    def explore_branch(
+        current_node_id: str,
+        branch_id: str,
+        branch_name: str,
+        depth: int,
+        input_data: Dict[str, Any],
+        path: List[str],
+        branch_visited: set,
+        steps: List[FlowStep],
+        parent_outputs: Dict[str, Any] = None,
+    ) -> None:
+        nonlocal branch_counter
+
+        if current_node_id in path:
+            return
+
+        current_node = node_map.get(current_node_id)
+        if not current_node:
+            return
+
+        is_input_node = current_node.data.get("type") == "type"
+        if is_input_node:
+            outputs_array = current_node.data["outputs"].get("properties", [])
+            first_output_name = (
+                outputs_array[0].get("name", "output") if outputs_array else "output"
+            )
+            current_outputs = {first_output_name: initial_value}
+        else:
+            if current_node_id in enricher_outputs:
+                current_outputs = enricher_outputs[current_node_id]
+            else:
+                current_outputs = process_node_data(current_node, input_data)
+                enricher_outputs[current_node_id] = current_outputs
+
+        node_params = current_node.data.get("params", {})
+
+        current_step = create_step(
+            current_node_id,
+            branch_id,
+            depth,
+            input_data,
+            is_input_node,
+            current_outputs,
+            node_params,
+        )
+        steps.append(current_step)
+        path.append(current_node_id)
+        branch_visited.add(current_node_id)
+
+        out_edges = get_outgoing_edges(current_node_id)
+
+        if not out_edges:
+            branches.append(FlowBranch(id=branch_id, name=branch_name, steps=steps[:]))
+        else:
+            for i, edge in enumerate(out_edges):
+                if edge.target in path:
+                    continue
+
+                output_key = edge.sourceHandle
+                if not output_key and current_outputs:
+                    output_key = list(current_outputs.keys())[0]
+
+                output_value = current_outputs.get(output_key) if output_key else None
+                if output_value is None and parent_outputs:
+                    output_value = (
+                        parent_outputs.get(output_key) if output_key else None
+                    )
+
+                next_input = {edge.targetHandle or "input": output_value}
+
+                if i == 0:
+                    explore_branch(
+                        edge.target,
+                        branch_id,
+                        branch_name,
+                        depth + 1,
+                        next_input,
+                        path,
+                        branch_visited,
+                        steps,
+                        current_outputs,
+                    )
+                else:
+                    branch_counter += 1
+                    new_branch_id = f"{branch_id}-{branch_counter}"
+                    new_branch_name = f"{branch_name} (Branch {branch_counter})"
+                    new_steps = steps[: len(steps)]
+                    new_branch_visited = branch_visited.copy()
+                    explore_branch(
+                        edge.target,
+                        new_branch_id,
+                        new_branch_name,
+                        depth + 1,
+                        next_input,
+                        path[:],
+                        new_branch_visited,
+                        new_steps,
+                        current_outputs,
+                    )
+
+        path.pop()
+        steps.pop()
+
+    for index, input_node in enumerate(input_nodes):
+        branch_id = f"branch-{index}"
+        branch_name = f"Flow {index + 1}" if len(input_nodes) > 1 else "Main Flow"
+        explore_branch(
+            input_node.id,
+            branch_id,
+            branch_name,
+            0,
+            {},
+            [],
+            set(),
+            [],
+            None,
+        )
+
+    branches.sort(key=lambda branch: len(branch.steps))
+    return branches
+
+
+def process_node_data(node: FlowNode, inputs: Dict[str, Any]) -> Dict[str, Any]:
+    """Process node data based on node type and inputs"""
+    outputs = {}
+    output_types = node.data["outputs"].get("properties", [])
+
+    for output in output_types:
+        output_name = output.get("name", "output")
+        class_name = node.data.get("class_name", "")
+
+        if class_name in ["ReverseResolveEnricher", "ResolveEnricher"]:
+            outputs[output_name] = (
+                "192.168.1.1" if "ip" in output_name.lower() else "example.com"
+            )
+        elif class_name == "SubdomainEnricher":
+            outputs[output_name] = f"sub.{inputs.get('input', 'example.com')}"
+        elif class_name == "WhoisEnricher":
+            outputs[output_name] = {
+                "domain": inputs.get("input", "example.com"),
+                "registrar": "Example Registrar",
+                "creation_date": "2020-01-01",
+            }
+        elif class_name == "IpToInfosEnricher":
+            outputs[output_name] = {
+                "country": "France",
+                "city": "Paris",
+                "coordinates": {"lat": 48.8566, "lon": 2.3522},
+            }
+        elif class_name == "MaigretEnricher":
+            outputs[output_name] = {
+                "username": inputs.get("input", "user123"),
+                "platforms": ["twitter", "github", "linkedin"],
+            }
+        elif class_name == "HoleheEnricher":
+            outputs[output_name] = {
+                "email": inputs.get("input", "user@example.com"),
+                "exists": True,
+                "platforms": ["gmail", "github"],
+            }
+        elif class_name == "SireneEnricher":
+            outputs[output_name] = {
+                "name": inputs.get("input", "Example Corp"),
+                "siret": "12345678901234",
+                "address": "1 Example Street",
+            }
+        else:
+            outputs[output_name] = inputs.get("input") or f"flowed_{output_name}"
+
+    return outputs

+ 267 - 0
flowsint-api/app/api/routes/investigations.py

@@ -0,0 +1,267 @@
+from uuid import UUID
+from fastapi import APIRouter, HTTPException, Depends, status
+from typing import List
+from sqlalchemy.orm import Session
+
+from flowsint_core.core.types import Role
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.models import Profile
+from flowsint_core.core.services import (
+    create_investigation_service,
+    NotFoundError,
+    PermissionDeniedError,
+    ConflictError,
+    DatabaseError,
+)
+from app.api.deps import get_current_user
+from app.api.schemas.investigation import (
+    InvestigationRead,
+    InvestigationCreate,
+    InvestigationUpdate,
+    CollaboratorAdd,
+    CollaboratorUpdate,
+    CollaboratorRead,
+)
+from app.api.schemas.sketch import SketchRead
+
+router = APIRouter()
+
+
+def _inject_current_user_role(service, investigation, user_id) -> InvestigationRead:
+    """Build InvestigationRead with the current user's role attached."""
+    result = InvestigationRead.model_validate(investigation)
+    role_entry = service.get_user_role_for_investigation(user_id, investigation.id)
+    if role_entry and role_entry.roles:
+        result.current_user_role = role_entry.roles[0].value
+    return result
+
+
+@router.get("", response_model=List[InvestigationRead])
+def get_investigations(
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get all investigations accessible to the user based on their roles."""
+    service = create_investigation_service(db)
+    allowed_roles = [Role.OWNER, Role.ADMIN, Role.EDITOR, Role.VIEWER]
+    investigations = service.get_accessible_investigations(
+        user_id=current_user.id, allowed_roles=allowed_roles
+    )
+    return [
+        _inject_current_user_role(service, inv, current_user.id)
+        for inv in investigations
+    ]
+
+
+@router.post(
+    "/create", response_model=InvestigationRead, status_code=status.HTTP_201_CREATED
+)
+def create_investigation(
+    payload: InvestigationCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    investigation = service.create(
+        name=payload.name,
+        description=payload.description,
+        owner_id=current_user.id,
+    )
+    return _inject_current_user_role(service, investigation, current_user.id)
+
+
+@router.get("/{investigation_id}", response_model=InvestigationRead)
+def get_investigation_by_id(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        investigation = service.get_by_id(investigation_id, current_user.id)
+        return _inject_current_user_role(service, investigation, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Investigation not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.get("/{investigation_id}/sketches", response_model=List[SketchRead])
+def get_sketches_by_investigation(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        return service.get_sketches(investigation_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(
+            status_code=404, detail="No sketches found for this investigation"
+        )
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.put("/{investigation_id}", response_model=InvestigationRead)
+def update_investigation(
+    investigation_id: UUID,
+    payload: InvestigationUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        investigation = service.update(
+            investigation_id=investigation_id,
+            user_id=current_user.id,
+            name=payload.name,
+            description=payload.description,
+            status=payload.status,
+        )
+        return _inject_current_user_role(service, investigation, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Investigation not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.delete("/{investigation_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_investigation(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        service.delete(investigation_id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Investigation not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to clean up graph data")
+
+
+# ── Collaborator endpoints ───────────────────────────────────────────
+
+
+@router.get(
+    "/{investigation_id}/collaborators", response_model=List[CollaboratorRead]
+)
+def get_collaborators(
+    investigation_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        entries = service.get_collaborators(investigation_id, current_user.id)
+        return [
+            CollaboratorRead(
+                id=e.id,
+                user_id=e.user_id,
+                roles=[r.value for r in e.roles],
+                user=e.user,
+            )
+            for e in entries
+        ]
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.post(
+    "/{investigation_id}/collaborators",
+    response_model=CollaboratorRead,
+    status_code=status.HTTP_201_CREATED,
+)
+def add_collaborator(
+    investigation_id: UUID,
+    payload: CollaboratorAdd,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        role = Role(payload.role.lower())
+    except ValueError:
+        raise HTTPException(status_code=400, detail="Invalid role")
+    try:
+        entry = service.add_collaborator(
+            investigation_id=investigation_id,
+            user_id=current_user.id,
+            target_email=payload.email,
+            role=role,
+        )
+        return CollaboratorRead(
+            id=entry.id,
+            user_id=entry.user_id,
+            roles=[r.value for r in entry.roles],
+            user=entry.user,
+        )
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e.message))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except ConflictError:
+        raise HTTPException(status_code=409, detail="User is already a collaborator")
+
+
+@router.put(
+    "/{investigation_id}/collaborators/{user_id}",
+    response_model=CollaboratorRead,
+)
+def update_collaborator_role(
+    investigation_id: UUID,
+    user_id: UUID,
+    payload: CollaboratorUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        role = Role(payload.role.lower())
+    except ValueError:
+        raise HTTPException(status_code=400, detail="Invalid role")
+    try:
+        entry = service.update_collaborator_role(
+            investigation_id=investigation_id,
+            user_id=current_user.id,
+            target_user_id=user_id,
+            role=role,
+        )
+        return CollaboratorRead(
+            id=entry.id,
+            user_id=entry.user_id,
+            roles=[r.value for r in entry.roles],
+            user=entry.user,
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Collaborator not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.delete(
+    "/{investigation_id}/collaborators/{user_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+)
+def remove_collaborator(
+    investigation_id: UUID,
+    user_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_investigation_service(db)
+    try:
+        service.remove_collaborator(
+            investigation_id=investigation_id,
+            user_id=current_user.id,
+            target_user_id=user_id,
+        )
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Collaborator not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")

+ 105 - 0
flowsint-api/app/api/routes/keys.py

@@ -0,0 +1,105 @@
+from typing import List
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    DatabaseError,
+    NotFoundError,
+    create_key_service,
+)
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.key import KeyCreate, KeyExists, KeyRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[KeyRead])
+def get_keys(
+    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
+):
+    service = create_key_service(db)
+    keys = service.get_keys_for_user(current_user.id)
+    return [
+        KeyRead(
+            id=key.id,
+            owner_id=key.owner_id,
+            name=key.name,
+            created_at=key.created_at,
+        )
+        for key in keys
+    ]
+
+
+@router.get("/chat-key-exists", response_model=KeyExists)
+def chat_key_exists(
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """
+    A simple util route to know if any ai chat key exists in the vault for this user
+    """
+    service = create_key_service(db)
+    try:
+        key_exists = service.chat_key_exist(current_user.id)
+        return KeyExists(exists=key_exists)
+    except NotFoundError as e:
+        print(e)
+        return KeyExists(exists=False)
+
+
+@router.get("/{id}", response_model=KeyRead)
+def get_key_by_id(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_key_service(db)
+    try:
+        key = service.get_key_by_id(id, current_user.id)
+        return KeyRead(
+            id=key.id,
+            owner_id=key.owner_id,
+            name=key.name,
+            created_at=key.created_at,
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Key not found")
+
+
+@router.post("/create", response_model=KeyRead, status_code=status.HTTP_201_CREATED)
+def create_key(
+    payload: KeyCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_key_service(db)
+    try:
+        key = service.create_key(payload.name, payload.key, current_user.id)
+        return KeyRead(
+            id=key.id,
+            owner_id=key.owner_id,
+            name=key.name,
+            created_at=key.created_at,
+        )
+    except DatabaseError:
+        raise HTTPException(
+            status_code=500, detail="An error occurred creating the key."
+        )
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_key(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_key_service(db)
+    try:
+        service.delete_key(id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Key not found")

+ 59 - 0
flowsint-api/app/api/routes/scan.py

@@ -0,0 +1,59 @@
+from typing import List
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    NotFoundError,
+    PermissionDeniedError,
+    create_scan_service,
+)
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.scan import ScanRead
+
+router = APIRouter()
+
+
+@router.get("/sketch/{id}", response_model=List[ScanRead])
+def get_scans(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get all scans accessible to the current user, linked to a sketch."""
+    service = create_scan_service(db)
+    return service.get_accessible_scans_by_sketch_id(current_user.id, id)
+
+
+@router.get("/{id}", response_model=ScanRead)
+def get_scan_by_id(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_scan_service(db)
+    try:
+        return service.get_by_id(id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Scan not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_scan_by_id(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_scan_service(db)
+    try:
+        service.delete(id, current_user.id)
+        return None
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Scan not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")

+ 532 - 0
flowsint-api/app/api/routes/sketches.py

@@ -0,0 +1,532 @@
+from typing import Any, Dict, List, Literal, Optional
+from uuid import UUID
+
+from fastapi import (
+    APIRouter,
+    BackgroundTasks,
+    Depends,
+    File,
+    Form,
+    HTTPException,
+    UploadFile,
+    status,
+)
+from flowsint_core.core.graph import GraphNode
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import (
+    create_sketch_service,
+    NotFoundError,
+    PermissionDeniedError,
+    ValidationError,
+    DatabaseError,
+)
+from flowsint_core.core.services.type_registry_service import create_type_registry_service
+from flowsint_core.imports import (
+    EntityMapping,
+    ImportService,
+    create_import_service,
+    FileParseResult,
+)
+from flowsint_core.core.graph import create_graph_service
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_user
+from app.api.schemas.sketch import SketchCreate, SketchRead, SketchUpdate
+from app.api.sketch_utils import update_sketch_timestamp
+
+router = APIRouter()
+
+
+class NodeData(BaseModel):
+    label: str = Field(default="Node", description="Label/name of the node")
+    type: str = Field(default="Node", description="Type of the node")
+
+    class Config:
+        extra = "allow"
+
+
+class NodeDeleteInput(BaseModel):
+    nodeIds: List[str]
+
+
+class RelationshipDeleteInput(BaseModel):
+    relationshipIds: List[str]
+
+
+class NodeEditInput(BaseModel):
+    nodeId: str
+    updates: Dict[str, Any]
+
+
+class RelationshipEditInput(BaseModel):
+    relationshipId: str
+    data: Dict[str, Any] = Field(
+        default_factory=dict, description="Updated data for the relationship"
+    )
+
+
+class NodeMergeInput(BaseModel):
+    id: str
+    data: NodeData = Field(
+        default_factory=NodeData, description="Updated data for the node"
+    )
+
+
+class RelationInput(BaseModel):
+    source: str
+    target: str
+    type: Literal["one-way", "two-way"]
+    label: str = "RELATED_TO"
+
+
+class NodePosition(BaseModel):
+    nodeId: str
+    x: float
+    y: float
+
+
+class UpdatePositionsInput(BaseModel):
+    positions: List[NodePosition]
+
+
+class EntityMappingInput(BaseModel):
+    """Pydantic model for parsing entity mapping input from frontend."""
+    id: str
+    entity_type: str
+    include: bool = True
+    nodeLabel: str
+    node_id: Optional[str] = None
+    data: Dict[str, Any]
+
+
+class ImportExecuteResponse(BaseModel):
+    """Response model for import execution."""
+    status: str
+    nodes_created: int
+    nodes_skipped: int
+    errors: List[str]
+
+
+@router.post("/create", response_model=SketchRead, status_code=status.HTTP_201_CREATED)
+def create_sketch(
+    data: SketchCreate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        sketch_data = data.model_dump()
+        return service.create(
+            title=sketch_data.get("title"),
+            description=sketch_data.get("description"),
+            investigation_id=sketch_data.get("investigation_id"),
+            owner_id=current_user.id,
+        )
+    except ValidationError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.get("", response_model=List[SketchRead])
+def list_sketches(
+    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
+):
+    service = create_sketch_service(db)
+    return service.list_sketches(current_user.id)
+
+
+@router.get("/{sketch_id}")
+def get_sketch_by_id(
+    sketch_id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.get_by_id(sketch_id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.put("/{id}", response_model=SketchRead)
+def update_sketch(
+    id: UUID,
+    payload: SketchUpdate,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.update(id, current_user.id, payload.model_dump(exclude_unset=True))
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.delete("/{id}", status_code=204)
+def delete_sketch(
+    id: UUID,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        service.delete(id, current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to clean up graph data")
+
+
+@router.get("/{sketch_id}/graph")
+async def get_sketch_nodes(
+    sketch_id: str,
+    format: str | None = None,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Get the nodes and edges for a sketch."""
+    service = create_sketch_service(db)
+    try:
+        return service.get_graph(UUID(sketch_id), current_user.id, format)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Graph not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+
+@router.post("/{sketch_id}/nodes/add")
+@update_sketch_timestamp
+def add_node(
+    sketch_id: str,
+    node: GraphNode,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.add_node(UUID(sketch_id), current_user.id, node)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except ValidationError:
+        raise HTTPException(status_code=400, detail="Node creation failed")
+    except DatabaseError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/{sketch_id}/relations/add")
+@update_sketch_timestamp
+def add_edge(
+    sketch_id: str,
+    relation: RelationInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.add_relationship(
+            UUID(sketch_id), current_user.id, relation.source, relation.target, relation.label
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except ValidationError:
+        raise HTTPException(status_code=400, detail="Edge creation failed")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to create edge")
+
+
+@router.put("/{sketch_id}/nodes/edit")
+@update_sketch_timestamp
+def edit_node(
+    sketch_id: str,
+    node_edit: NodeEditInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.update_node(
+            UUID(sketch_id), current_user.id, node_edit.nodeId, node_edit.updates
+        )
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to update node")
+
+
+@router.put("/{sketch_id}/nodes/positions")
+@update_sketch_timestamp
+def update_node_positions(
+    sketch_id: str,
+    data: UpdatePositionsInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Update positions (x, y) for multiple nodes in batch."""
+    service = create_sketch_service(db)
+    try:
+        positions = [pos.model_dump() for pos in data.positions]
+        return service.update_node_positions(UUID(sketch_id), current_user.id, positions)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to update node positions")
+
+
+@router.delete("/{sketch_id}/nodes")
+@update_sketch_timestamp
+def delete_nodes(
+    sketch_id: str,
+    nodes: NodeDeleteInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.delete_nodes(UUID(sketch_id), current_user.id, nodes.nodeIds)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to delete nodes")
+
+
+@router.delete("/{sketch_id}/relationships")
+@update_sketch_timestamp
+def delete_relationships(
+    sketch_id: str,
+    relationships: RelationshipDeleteInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.delete_relationships(
+            UUID(sketch_id), current_user.id, relationships.relationshipIds
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to delete relationships")
+
+
+@router.put("/{sketch_id}/relationships/edit")
+@update_sketch_timestamp
+def edit_relationship(
+    sketch_id: str,
+    relationship_edit: RelationshipEditInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.update_relationship(
+            UUID(sketch_id),
+            current_user.id,
+            relationship_edit.relationshipId,
+            relationship_edit.data,
+        )
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to update relationship")
+
+
+@router.post("/{sketch_id}/nodes/merge")
+@update_sketch_timestamp
+def merge_nodes(
+    sketch_id: str,
+    oldNodes: List[str],
+    newNode: NodeMergeInput,
+    background_tasks: BackgroundTasks,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        node_data = newNode.data.model_dump() if newNode.data else {}
+        return service.merge_nodes(
+            UUID(sketch_id), current_user.id, oldNodes, newNode.id, node_data
+        )
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except ValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except DatabaseError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/{sketch_id}/nodes/{node_id}")
+def get_related_nodes(
+    sketch_id: str,
+    node_id: str,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    service = create_sketch_service(db)
+    try:
+        return service.get_neighbors(UUID(sketch_id), current_user.id, node_id)
+    except NotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except DatabaseError:
+        raise HTTPException(status_code=500, detail="Failed to retrieve related nodes")
+
+
+@router.post("/{sketch_id}/import/analyze", response_model=FileParseResult)
+async def analyze_import_file(
+    sketch_id: str,
+    file: UploadFile = File(...),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Analyze an uploaded TXT or JSON file for import."""
+    service = create_sketch_service(db)
+    try:
+        service.get_by_id(UUID(sketch_id), current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+    if not file.filename or not file.filename.lower().endswith((".txt", ".json")):
+        raise HTTPException(
+            status_code=400,
+            detail="Only .txt and .json files are supported. Please upload a correct format.",
+        )
+
+    try:
+        content = await file.read()
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=f"Failed to read file: {str(e)}")
+
+    try:
+        type_registry = create_type_registry_service(db)
+        resolver = type_registry.build_type_resolver(current_user.id)
+        graph_service = create_graph_service(sketch_id=sketch_id, enable_batching=False, type_resolver=resolver)
+        import_service = create_import_service(graph_service)
+        result = import_service.analyze_file(
+            file_content=content,
+            filename=file.filename or "unknown.txt",
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to parse file: {str(e)}")
+
+    return result
+
+
+@router.post("/{sketch_id}/import/execute", response_model=ImportExecuteResponse)
+@update_sketch_timestamp
+async def execute_import(
+    sketch_id: str,
+    entity_mappings_json: str = Form(...),
+    background_tasks: BackgroundTasks = BackgroundTasks(),
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Execute the import of entities into the sketch."""
+    import json
+
+    service = create_sketch_service(db)
+    try:
+        service.get_by_id(UUID(sketch_id), current_user.id)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+
+    try:
+        mappings = json.loads(entity_mappings_json)
+        nodes = mappings.get("nodes", [])
+        edges = mappings.get("edges", [])
+        entity_mapping_inputs = [EntityMappingInput(**m) for m in nodes]
+    except json.JSONDecodeError:
+        raise HTTPException(status_code=400, detail="Invalid entity_mappings JSON")
+    except Exception as e:
+        raise HTTPException(
+            status_code=400, detail=f"Failed to parse entity_mappings: {str(e)}"
+        )
+
+    entity_mappings = [
+        EntityMapping(
+            id=m.id,
+            entity_type=m.entity_type,
+            nodeLabel=m.nodeLabel,
+            data=m.data,
+            include=m.include,
+            node_id=m.node_id,
+        )
+        for m in entity_mapping_inputs
+    ]
+
+    type_registry = create_type_registry_service(db)
+    resolver = type_registry.build_type_resolver(current_user.id)
+    graph_service = create_graph_service(sketch_id=sketch_id, enable_batching=False, type_resolver=resolver)
+    import_service = create_import_service(graph_service)
+
+    try:
+        result = import_service.execute_import(
+            entity_mappings=entity_mappings,
+            edges=edges,
+        )
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
+
+    return ImportExecuteResponse(
+        status=result.status,
+        nodes_created=result.nodes_created,
+        nodes_skipped=result.nodes_skipped,
+        errors=result.errors,
+    )
+
+
+@router.get("/{id}/export")
+async def export_sketch(
+    id: str,
+    format: str = "json",
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Export the sketch in the specified format."""
+    service = create_sketch_service(db)
+    try:
+        return service.export_sketch(UUID(id), current_user.id, format)
+    except NotFoundError:
+        raise HTTPException(status_code=404, detail="Sketch not found")
+    except PermissionDeniedError:
+        raise HTTPException(status_code=403, detail="Forbidden")
+    except ValidationError as e:
+        raise HTTPException(status_code=400, detail=str(e))

+ 38 - 0
flowsint-api/app/api/routes/types.py

@@ -0,0 +1,38 @@
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from flowsint_core.core.models import Profile
+from flowsint_core.core.postgre_db import get_db
+from flowsint_core.core.services import create_type_registry_service
+from app.api.deps import get_current_user
+
+router = APIRouter()
+
+
+@router.get("")
+async def get_types_list(
+    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
+):
+    """Get the complete types list for sketches."""
+    service = create_type_registry_service(db)
+    return service.get_types_list(current_user.id)
+
+
+class DetectRequest(BaseModel):
+    text: str
+
+
+@router.post("/detect")
+async def detect_type(
+    body: DetectRequest,
+    db: Session = Depends(get_db),
+    current_user: Profile = Depends(get_current_user),
+):
+    """Detect the type of a given text input.
+
+    Returns the detected type and its fields with the primary field pre-filled.
+    Falls back to Phrase if no type matches.
+    """
+    service = create_type_registry_service(db)
+    return service.detect_type(body.text)

+ 0 - 0
flowsint-api/app/api/schemas/__init__.py


+ 32 - 0
flowsint-api/app/api/schemas/analysis.py

@@ -0,0 +1,32 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional, Any
+from datetime import datetime
+
+
+class AnalysisCreate(BaseModel):
+    title: str
+    description: Optional[str] = None
+    content: Optional[Any] = None
+    owner_id: Optional[UUID4] = None
+    investigation_id: Optional[UUID4] = None
+
+
+class AnalysisRead(ORMBase):
+    id: UUID4
+    title: str
+    description: Optional[str]
+    content: Optional[Any]
+    created_at: datetime
+    last_updated_at: datetime
+    owner_id: Optional[UUID4]
+    investigation_id: Optional[UUID4]
+
+
+class AnalysisUpdate(BaseModel):
+    title: Optional[str] = None
+    description: Optional[str] = None
+    content: Optional[Any] = None
+    last_updated_at: Optional[datetime] = None
+    owner_id: Optional[UUID4] = None
+    investigation_id: Optional[UUID4] = None

+ 6 - 0
flowsint-api/app/api/schemas/base.py

@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+
+class ORMBase(BaseModel):
+    class Config:
+        from_attributes = True

+ 34 - 0
flowsint-api/app/api/schemas/chat.py

@@ -0,0 +1,34 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import List, Optional, Any
+from datetime import datetime
+
+
+class ChatMessageRead(BaseModel):
+    id: UUID4
+    content: Optional[Any] = None
+    is_bot: bool
+    created_at: datetime
+    chat_id: UUID4
+    context: Optional[Any] = None
+
+    class Config:
+        from_attributes = True
+
+
+class ChatCreate(BaseModel):
+    title: str
+    description: Optional[str] = None
+    owner_id: Optional[UUID4] = None
+    investigation_id: Optional[UUID4] = None
+
+
+class ChatRead(ORMBase):
+    id: UUID4
+    title: str
+    description: Optional[str]
+    created_at: datetime
+    last_updated_at: datetime
+    owner_id: Optional[UUID4]
+    investigation_id: Optional[UUID4]
+    messages: List[ChatMessageRead]

+ 88 - 0
flowsint-api/app/api/schemas/custom_type.py

@@ -0,0 +1,88 @@
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from pydantic import UUID4, BaseModel, Field, field_validator
+
+from .base import ORMBase
+
+
+class CustomTypeCreate(BaseModel):
+    """Schema for creating a new custom type."""
+
+    name: str = Field(
+        ..., min_length=1, max_length=255, description="Name of the custom type"
+    )
+    json_schema: Dict[str, Any] = Field(
+        ..., description="JSON Schema definition", alias="schema"
+    )
+    description: Optional[str] = Field(
+        None, description="Optional description of the custom type"
+    )
+    status: str = Field("draft", description="Status of the custom type")
+    color: str = Field("#8E9E8C", description="Default color")
+    icon: str = Field("Minus", description="Default icon")
+
+    class Config:
+        populate_by_name = True
+
+    @field_validator("status")
+    @classmethod
+    def validate_status(cls, v: str) -> str:
+        if v not in ["draft", "published", "archived"]:
+            raise ValueError("Status must be one of: draft, published, archived")
+        return v
+
+
+class CustomTypeUpdate(BaseModel):
+    """Schema for updating an existing custom type."""
+
+    name: Optional[str] = Field(None, min_length=1, max_length=255)
+    json_schema: Optional[Dict[str, Any]] = Field(None, alias="schema")
+    description: Optional[str] = None
+    status: Optional[str] = None
+    color: Optional[str] = None
+    icon: Optional[str] = None
+
+    class Config:
+        populate_by_name = True
+
+    @field_validator("status")
+    @classmethod
+    def validate_status(cls, v: Optional[str]) -> Optional[str]:
+        if v is not None and v not in ["draft", "published", "archived"]:
+            raise ValueError("Status must be one of: draft, published, archived")
+        return v
+
+
+class CustomTypeRead(ORMBase):
+    """Schema for reading a custom type."""
+
+    id: UUID4
+    name: str
+    owner_id: UUID4
+    color: Optional[str]
+    icon: Optional[str]
+    json_schema: Dict[str, Any] = Field(..., alias="schema")
+    status: str
+    checksum: Optional[str]
+    description: Optional[str]
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        populate_by_name = True
+
+
+class CustomTypeValidatePayload(BaseModel):
+    """Schema for validating a payload against a custom type schema."""
+
+    payload: Dict[str, Any] = Field(
+        ..., description="Data to validate against the schema"
+    )
+
+
+class CustomTypeValidateResponse(BaseModel):
+    """Response schema for validation."""
+
+    valid: bool
+    errors: Optional[list[str]] = None

+ 27 - 0
flowsint-api/app/api/schemas/enricher.py

@@ -0,0 +1,27 @@
+from typing import Any, Dict, List, Optional
+
+from pydantic import UUID4, BaseModel
+
+from .base import ORMBase
+
+
+class EnricherCreate(BaseModel):
+    name: str
+    description: Optional[str] = None
+    category: Optional[List[str]] = None
+
+
+class EnricherRead(ORMBase):
+    id: UUID4
+    name: str
+    class_name: str
+    description: Optional[str]
+    category: Optional[List[str]]
+    flow_schema: Optional[Dict[str, Any]]
+
+
+class EnricherUpdate(BaseModel):
+    name: Optional[str] = None
+    class_name: Optional[str] = None
+    description: Optional[str] = None
+    category: Optional[List[str]] = None

+ 190 - 0
flowsint-api/app/api/schemas/enricher_template.py

@@ -0,0 +1,190 @@
+"""Pydantic schemas for enricher templates."""
+
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from pydantic import UUID4, BaseModel, Field, field_validator
+
+from .base import ORMBase
+
+
+class EnricherTemplateCreate(BaseModel):
+    """Schema for creating a new enricher template."""
+
+    name: str = Field(
+        ..., min_length=1, max_length=255, description="Name of the template"
+    )
+    description: Optional[str] = Field(
+        None, max_length=1000, description="Description of the template"
+    )
+    category: str = Field(
+        ..., min_length=1, max_length=100, description="Category (e.g., Ip, Domain)"
+    )
+    version: float = Field(default=1.0, ge=0, description="Template version")
+    content: Dict[str, Any] = Field(
+        ..., description="Template content as parsed YAML/JSON"
+    )
+    is_public: bool = Field(
+        default=False, description="Whether the template is publicly visible"
+    )
+
+    @field_validator("content")
+    @classmethod
+    def validate_content(cls, v: Dict[str, Any]) -> Dict[str, Any]:
+        """Validate that content has required template fields."""
+        required_fields = [
+            "name",
+            "category",
+            "version",
+            "input",
+            "request",
+            "output",
+            "response",
+        ]
+        missing = [f for f in required_fields if f not in v]
+        if missing:
+            raise ValueError(
+                f"Missing required fields in content: {', '.join(missing)}"
+            )
+
+        # Validate input
+        if "input" in v and "type" not in v.get("input", {}):
+            raise ValueError("input.type is required")
+
+        # Validate request
+        request = v.get("request", {})
+        if "method" not in request:
+            raise ValueError("request.method is required")
+        if request.get("method") not in ["GET", "POST"]:
+            raise ValueError("request.method must be GET or POST")
+        if "url" not in request:
+            raise ValueError("request.url is required")
+
+        # Validate output
+        if "output" in v and "type" not in v.get("output", {}):
+            raise ValueError("output.type is required")
+
+        # Validate response
+        response = v.get("response", {})
+        if "expect" not in response:
+            raise ValueError("response.expect is required")
+        if response.get("expect") not in ["json", "xml", "text"]:
+            raise ValueError("response.expect must be json, xml, or text")
+
+        return v
+
+
+class EnricherTemplateUpdate(BaseModel):
+    """Schema for updating an existing enricher template."""
+
+    name: Optional[str] = Field(None, min_length=1, max_length=255)
+    description: Optional[str] = Field(None, max_length=1000)
+    category: Optional[str] = Field(None, min_length=1, max_length=100)
+    version: Optional[float] = Field(None, ge=0)
+    content: Optional[Dict[str, Any]] = None
+    is_public: Optional[bool] = None
+
+    @field_validator("content")
+    @classmethod
+    def validate_content(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+        """Validate content if provided."""
+        if v is None:
+            return v
+
+        required_fields = [
+            "name",
+            "category",
+            "version",
+            "input",
+            "request",
+            "output",
+            "response",
+        ]
+        missing = [f for f in required_fields if f not in v]
+        if missing:
+            raise ValueError(
+                f"Missing required fields in content: {', '.join(missing)}"
+            )
+
+        return v
+
+
+class EnricherTemplateRead(ORMBase):
+    """Schema for reading an enricher template."""
+
+    id: UUID4
+    name: str
+    description: Optional[str]
+    category: str
+    version: float
+    content: Dict[str, Any]
+    is_public: bool
+    owner_id: UUID4
+    created_at: datetime
+    updated_at: datetime
+
+
+class EnricherTemplateList(ORMBase):
+    """Schema for listing enricher templates (minimal fields)."""
+
+    id: UUID4
+    name: str
+    description: Optional[str]
+    category: str
+    version: float
+    is_public: bool
+    owner_id: UUID4
+    created_at: datetime
+    updated_at: datetime
+
+
+class EnricherTemplateTestRequest(BaseModel):
+    """Schema for testing an enricher template by ID."""
+
+    input_value: str = Field(
+        ..., min_length=1, description="The value to test the template with"
+    )
+
+
+class EnricherTemplateTestContentRequest(BaseModel):
+    """Schema for testing template content directly (without saving)."""
+
+    input_value: str = Field(
+        ..., min_length=1, description="The value to test the template with"
+    )
+    content: Dict[str, Any] = Field(..., description="Template content to test")
+
+
+class EnricherTemplateTestResponse(BaseModel):
+    """Schema for test response."""
+
+    success: bool
+    data: Optional[Dict[str, Any]] = None
+    error: Optional[str] = None
+    status_code: Optional[int] = None
+    url: str
+
+
+class EnricherTemplateGenerateRequest(BaseModel):
+    """Schema for AI-assisted template generation."""
+
+    prompt: str = Field(
+        ...,
+        min_length=10,
+        max_length=16000,
+        description="Free-text description of the desired enricher template",
+    )
+    input_type: Optional[str] = Field(
+        None, description="Flowsint input type name (e.g. 'Ip', 'Domain')"
+    )
+    output_type: Optional[str] = Field(
+        None, description="Flowsint output type name (e.g. 'Ip', 'SocialAccount')"
+    )
+
+
+class EnricherTemplateGenerateResponse(BaseModel):
+    """Schema for the generated template response."""
+
+    yaml_content: str = Field(
+        ..., description="Raw YAML string of the generated template"
+    )

+ 16 - 0
flowsint-api/app/api/schemas/feedback.py

@@ -0,0 +1,16 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class FeedbackCreate(BaseModel):
+    content: Optional[str] = None
+    owner_id: Optional[UUID4] = None
+
+
+class FeedbackRead(ORMBase):
+    id: int
+    created_at: datetime
+    content: Optional[str] = None
+    owner_id: Optional[UUID4]

+ 29 - 0
flowsint-api/app/api/schemas/flow.py

@@ -0,0 +1,29 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional
+from datetime import datetime
+from typing import List, Optional, Dict, Any
+
+
+class FlowCreate(BaseModel):
+    name: str
+    description: Optional[str] = None
+    category: Optional[List[str]] = None
+    flow_schema: Optional[Dict[str, Any]] = None
+
+
+class FlowRead(ORMBase):
+    id: UUID4
+    name: str
+    description: Optional[str]
+    category: Optional[List[str]]
+    flow_schema: Optional[Dict[str, Any]]
+    created_at: datetime
+    last_updated_at: datetime
+
+
+class FlowUpdate(BaseModel):
+    name: Optional[str] = None
+    description: Optional[str] = None
+    category: Optional[List[str]] = None
+    flow_schema: Optional[Dict[str, Any]] = None

+ 54 - 0
flowsint-api/app/api/schemas/investigation.py

@@ -0,0 +1,54 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional
+from datetime import datetime
+from .sketch import SketchRead
+from .analysis import AnalysisRead
+from .profile import ProfileRead
+
+
+class InvestigationCreate(BaseModel):
+    name: str
+    description: str
+    owner_id: Optional[UUID4] = None
+    status: Optional[str] = "active"
+
+
+class InvestigationRead(ORMBase):
+    id: UUID4
+    created_at: datetime
+    name: str
+    description: str
+    owner_id: Optional[UUID4]
+    last_updated_at: datetime
+    status: str
+    owner: Optional[ProfileRead] = None
+    sketches: list[SketchRead] = []
+    analyses: list[AnalysisRead] = []
+    current_user_role: Optional[str] = None
+
+
+class InvestigationUpdate(BaseModel):
+    name: str
+    description: Optional[str] = None
+    last_updated_at: datetime
+    status: str
+
+
+# ── Collaborator schemas ─────────────────────────────────────────────
+
+
+class CollaboratorAdd(BaseModel):
+    email: str
+    role: str  # "admin", "editor", "viewer"
+
+
+class CollaboratorUpdate(BaseModel):
+    role: str  # "admin", "editor", "viewer"
+
+
+class CollaboratorRead(ORMBase):
+    id: UUID4
+    user_id: UUID4
+    roles: list[str] = []
+    user: Optional[ProfileRead] = None

+ 18 - 0
flowsint-api/app/api/schemas/investigation_profiles.py

@@ -0,0 +1,18 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class InvestigationProfileCreate(BaseModel):
+    investigation_id: UUID4
+    profile_id: UUID4
+    role: Optional[str] = "member"
+
+
+class InvestigationProfileRead(ORMBase):
+    id: int
+    created_at: datetime
+    investigation_id: UUID4
+    profile_id: UUID4
+    role: str

+ 21 - 0
flowsint-api/app/api/schemas/key.py

@@ -0,0 +1,21 @@
+from datetime import datetime
+
+from pydantic import UUID4, BaseModel
+
+from .base import ORMBase
+
+
+class KeyCreate(BaseModel):
+    key: str
+    name: str
+
+
+class KeyRead(ORMBase):
+    id: UUID4
+    owner_id: UUID4
+    name: str
+    created_at: datetime | str
+
+
+class KeyExists(BaseModel):
+    exists: bool

+ 24 - 0
flowsint-api/app/api/schemas/profile.py

@@ -0,0 +1,24 @@
+from .base import ORMBase
+from pydantic import UUID4, BaseModel, ConfigDict, EmailStr
+from typing import Optional
+
+
+class ProfileCreate(BaseModel):
+    email: EmailStr
+    password: str
+
+
+class ProfileRead(ORMBase):
+    id: UUID4
+    first_name: Optional[str]
+    last_name: Optional[str]
+    avatar_url: Optional[str]
+    email: Optional[str] = None
+
+
+class ProfileUpdate(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    first_name: Optional[str] = None
+    last_name: Optional[str] = None
+    avatar_url: Optional[str] = None

+ 17 - 0
flowsint-api/app/api/schemas/scan.py

@@ -0,0 +1,17 @@
+from typing import List, Optional, Any
+from .base import ORMBase
+from pydantic import UUID4, BaseModel
+from typing import Optional
+
+
+class ScanCreate(BaseModel):
+    values: Optional[List[str]] = None
+    sketch_id: Optional[UUID4] = None
+    status: Optional[str] = None
+    details: Optional[Any] = None
+
+
+class ScanRead(ORMBase):
+    id: UUID4
+    sketch_id: Optional[UUID4]
+    status: Optional[str]

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor