Skip to content

Development Guide

zach115th edited this page Jul 16, 2026 · 9 revisions

Development Guide

Dev loop

After editing a Jinja template

docker restart iriswebapp_app

Gunicorn caches templates; auto_reload = False in the dev compose. A hard browser reload alone is useless — the server keeps sending the cached template.

After editing Python source (volume-mounted)

docker restart iriswebapp_app iriswebapp_worker iriswebapp_ai_worker

Python files under source/app/ are volume-mounted — edits are live in the container. A restart picks them up without a rebuild.

Exception: if you edited docker/webApp/iris-entrypoint.sh or gunicorn settings, you need a rebuild (the entrypoint is baked into the image).

After editing a static asset (ui/public/assets/)

Static assets are baked into the image at build time, not volume-mounted. Two steps:

# 1. Deploy to running container immediately
docker cp ui/public/assets/<path> iriswebapp_app://iriswebapp/static/<path>
# Always use PowerShell — Bash/MSYS translates paths incorrectly

# 2. Also edit the source file so the fix survives the next rebuild

Verify the cp landed before restarting:

docker exec iriswebapp_app grep -c '<something>' /iriswebapp/static/<path>

After editing a Vite-bundled JS file (ui/src/pages/)

cd ui && npm run build
docker compose -f docker-compose.dev.yml build app worker ai_worker
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps app worker ai_worker

Bundled JS (e.g. case.asset.js, case.rfiles.js) is compiled by Vite → baked into the image. No shortcut — full rebuild required.

Exception: ui/public/assets/js/iris/case.time.js is a static file (uses Vite publicDir), so it deploys via docker cp like any other static asset.

After adding an Alembic migration

docker exec iriswebapp_app alembic -c /iriswebapp/app/alembic.ini upgrade head

Or just restart — db.create_all() runs before Alembic and handles new tables. Column additions on existing tables still need Alembic to run.

After editing the nginx conf

nginx conf is baked into the nginx image with envsubst substituting ${VAR} placeholders. Do not docker cp the raw source file — it contains literal ${VAR} which crashes nginx.

docker compose -f docker-compose.dev.yml build nginx
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps nginx

Full image rebuild + DB-preserving recreate

docker compose -f docker-compose.dev.yml build app worker ai_worker
docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps app worker ai_worker

--no-deps keeps the DB and RabbitMQ containers running so no data is lost.

Upgrade (pull + rebuild)

git pull
docker compose -f docker-compose.dev.yml up -d --build --force-recreate

Always include --force-recreate — a plain up --build can leave worker / ai_worker on the old container while only app is rebuilt, causing NotImplementedError in task_hook_wrapper before any module runs.


Load-bearing gotchas

Import-free JS belongs in ui/public/, not ui/src/pages/

Vite 8 / rolldown 1.1.4 silently drops function declarations that have no callers within their own module — functions referenced only from HTML onclick handlers are treated as dead code.

Rule: any JS file with zero ES6 import statements belongs in ui/public/assets/js/iris/. Vite copies public/ verbatim; nothing is tree-shaken. JS files that import from $lib or npm packages belong in ui/src/pages/.

Every npm package referenced by a vite-plugin-static-copy target must also be declared in package.jsonnpm ci will not install transitive deps, and the plugin silently skips missing source paths (producing CDN 404s at runtime).

Use vite-plugin-static-copy ^3.3.0 or later for Vite 8 compatibility — 1.x and 2.x declare peer vite: "^5.0.0 || ^6.0.0" and fail to install against Vite 8.

Jinja {% inside <script> blocks

Jinja processes {% delimiters everywhere in the template file, including inside <script> tags and /* */ JS comments. Writing {% block javascripts %} in a JS comment causes TemplateSyntaxError. Write it in plain English.

jQuery load order

<script> blocks in {% block content %} run at browser parse time — before {% block javascripts %} where jQuery loads. Calling $() directly at IIFE top level throws ReferenceError: $ is not defined. Wrap jQuery wiring in:

document.addEventListener('DOMContentLoaded', function() {
  // $ is available here
});

Vanilla JS (document.getElementById, fetch, MutationObserver) is safe at IIFE top level.

CSRF on multipart requests

The JSON-body CSRF rule extends to multipart/form-data. IRIS-NG's CSRF validator reads request.form only; append csrf_token as a form field in FormData:

const body = new FormData();
body.append('file', file);
body.append('csrf_token', document.getElementById('csrf_token').value);

CSRF on pages with no WTForms form

To get a #csrf_token input on a page that has no WTForms form, pass a bare FlaskForm() to the template and render:

<form style="display:none;">{{ form.hidden_tag() }}</form>

{{ csrf_token() }} is not a Jinja global in this app — it renders empty.

.data() vs .attr() for numeric-looking data-* values

jQuery .data('cluster-id') auto-casts numeric-looking strings to integers. Use .attr('data-cluster-id') when the value needs to stay a string for === comparison against server-returned strings.

Inline onclick with user values

Never embed user-controlled values inside onclick="fn('...')" string literals. Any .replace() applied after HTML-escaping misses backslashes and other metacharacters. Use data-* attributes + a delegated handler instead:

<button data-case-id="{{ case.case_id }}">Click</button>
document.addEventListener('click', function(e) {
  const btn = e.target.closest('[data-case-id]');
  if (!btn) return;
  const id = btn.getAttribute('data-case-id');  // .attr(), not .data()
});

String .replace() in normalization/sanitization

str.replace('x', 'y') only replaces the first occurrence. Use /regex/g:

phaseName.replace(/&/g, 'and')

label for= must match input id= exactly

Mismatches break autofill, a11y, and click-to-focus. Always set for= to the input's id=, not a descriptive name.

filterXSS / sanitizeHTML is not an attribute escape

filterXSS sanitizes HTML tag/attribute names — it does not escape " inside attribute values. Use irisAttrEscape() (defined in suggesttag.js) when interpolating values into data-val="..." or any HTML attribute.

Chart.js in a hidden Bootstrap tab

Defer chart rendering to shown.bs.tab — hidden tab panes have offsetWidth = 0 and Chart.js draws all elements at (0,0).

Chart.js responsive + flip-card

In a Bootstrap flip-card back face, defer the render() call 50 ms after showing the chart slot (CSS transition from position:absolute to normal flow takes one frame).

Alembic begin_transaction()

The begin_transaction() wrapper in source/app/alembic/env.py was commented out upstream, causing all ALTER TABLE migrations to silently fail. iris-ng restores it. Watch for accidental reverts on upstream cherry-picks.

ORM CHECK constraints belong on the model, not just the migration

db.create_all() runs before Alembic. If a CHECK constraint is only in the migration file, _has_table guard skips op.create_table on fresh stacks and the constraint never lands. Always define CHECK + UNIQUE constraints on the ORM model's __table_args__.

Never hash wall-clock values into a content cache key

input_hash should include only what changes when the data changes, not when the clock advances. A field like hours_since_last_activity (derived from utcnow()) changes every call — hash the underlying updated_at timestamps instead.

Thread pool workers need their own Flask app context

Workers in a ThreadPoolExecutor do not inherit the calling thread's app context:

def _worker(app, ...):
    with app.app_context():
        ...  # ORM queries here

Return primitive IDs from workers; re-fetch ORM objects in the main thread after the pool completes.

Celery fork-safety = NullPool for workers

Configured in source/app/__init__.py when "worker" in sys.argv. Do not add per-module db.session.remove() shims — they detach ORM objects passed in by task_hook_wrapper.

Seed catalogs need slug-keyed upserts, not create_safe

create_safe matches on ALL kwargs; a name or description edit creates a duplicate. Slug/key-keyed upsert:

existing = db.session.query(Skill).filter_by(slug=slug).first()
if not existing:
    db.session.add(Skill(slug=slug, ...))

v2 API response shape has no wrapper

/api/v2/ returns the payload directly — no {status, message, data} envelope. Reading resp.data on a v2 response returns undefined.

Jinja guard for shared add/edit modals

Modals shared between add (user=None) and edit (user=<dict>) paths: {% if user and user.some_field is not none %} — not just {% if user.some_field ... %}. x.attr is not none still evaluates x.attr and raises UndefinedError when x is None.


Adding new features

New Alembic migration

docker exec iriswebapp_app alembic -c /iriswebapp/app/alembic.ini revision \
  --autogenerate -m "add_my_feature"

Review the generated file — autogenerate doesn't catch everything. Always define CHECK + UNIQUE constraints on the model's __table_args__ (not just the migration).

New /api/v2/ blueprint

  1. Create source/app/blueprints/rest/v2/<name>/__init__.py
  2. Register the blueprint in source/app/__init__.py
  3. Follow the three-layer rule: blueprint → business → datamgmt
  4. Return data directly (no response_success() wrapper)
  5. Require CSRF on POST/PUT/DELETE
  6. Check access control with the existing ac_api_requires() decorator

New AI orchestrator

See AI Features → Adding a new AI orchestrator.

New working-timeline ingest source

See Dual Timeline → Adding a new ingest source.

New M2M relationship (inverse-chip pattern)

The inverse-chip pattern (violet pill chips in modal B linking back to modal A) is established across IOC↔Note and Asset↔Evidence. Chip styling:

  • Background: rgba(139, 92, 246, 0.12)
  • Border: rgba(139, 92, 246, 0.35)
  • Text: #d4c4ff
  • Deep-link: ?shared=<id> auto-opens the linked modal

GitHub Actions

Every workflow file needs an explicit permissions: block immediately after on::

permissions:
  contents: read

Add job-level permissions: for any job needing additional scope — don't promote to workflow level.


Security patterns

Rule File
Path injection: use allowlist dict, not f-string is_safe_url
Tag search: POST /tags/index not GET /tags/search/<term> misp_sync_client.py
Never log secret[:N] — log length instead seed_2025_test_case.py
Strip config sub-keys from printed output iris_misp_sync_dev.py
Open-redirect: allowlist paths starting with /, no scheme, no netloc, no // is_safe_url

Clone this wiki locally