Files
ImpTune/.planning/phases/01-foundation/01-RESEARCH.md
T
2026-04-15 17:57:12 +02:00

32 KiB

Phase 1: Foundation - Research

Researched: 2026-04-10 Domain: Docker container scaffold, SQLite schema with Peewee ORM, .intunewin format spike (Python-native AES-256-CBC) Confidence: HIGH (Docker/Peewee patterns), MEDIUM (.intunewin byte-level format — must be validated against real Intune tenant)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • App shell & navigation: Persistent left sidebar with flat, equal-weight sections: Dashboard, Drivers, Printers, Clients, Packages. Dashboard is the landing page: quick action buttons at top ("New Printer", "Upload Driver", "Export Package") plus recent printers/packages list below. System/auto theme — follow OS dark/light preference.
  • CSS & offline access: Air-gapped deployment — no CDN access from the server; all assets must be bundled in the Docker image. Use a lightweight pre-built CSS framework (e.g., Pico CSS) instead of Tailwind — no build step, just a static CSS file. HTMX and Alpine.js downloaded during Docker image build (ADD/curl), baked into the image as static files. All JS/CSS served from the container's static files directory — zero external requests at runtime.
  • Database schema: Full schema created upfront in Phase 1 — all tables for phases 2-5 (drivers, printers, clients, icons). Peewee ORM for all database operations. Schema auto-created on first run via Peewee's create_tables().
  • Driver storage: SHA256 content-addressed storage for driver files on the Docker volume. Deduplication: same file uploaded twice results in one copy on disk. SQLite stores hash reference + original filename + metadata; filesystem stores actual files.

Claude's Discretion

  • Specific lightweight CSS framework selection (Pico CSS, Simple.css, or similar)
  • Dashboard layout details and empty state design
  • Exact color scheme for light and dark themes
  • Project directory structure (guided by ARCHITECTURE.md research)
  • .intunewin spike implementation details

Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope. </user_constraints>


<phase_requirements>

Phase Requirements

ID Description Research Support
INFRA-01 Application runs as a single Docker container Docker scaffold plan (Dockerfile + docker-compose.yml); python:3.12-slim-bookworm base; no sidecar services
INFRA-02 Application has minimal runtime dependencies (no Node.js, no external DB) Pico CSS + HTMX + Alpine.js baked into image at build time; SQLite via Peewee (stdlib + one pip package); no Node build pipeline
</phase_requirements>

Summary

Phase 1 delivers three things: a running Docker container with the app scaffold, the complete SQLite schema initialized via Peewee, and a validated Python-native .intunewin generator. These are independent workstreams that can be built in parallel but must converge before Phase 2 starts.

The Docker scaffold is low-risk and well-understood. The base image is python:3.12-slim-bookworm (never Alpine — C-extension wheels fail on musl libc). All frontend assets (Pico CSS, HTMX, Alpine.js) are downloaded with curl during the Docker build and served as static files. There are zero external HTTP requests at container runtime — a hard requirement for air-gapped MSP networks.

The SQLite schema via Peewee is also straightforward, but the Phase 1 decision to create the full schema upfront (all tables for phases 2-5) means the models file must define every table now. The .intunewin format spike is the highest-risk item: the format is reverse-engineered (MEDIUM confidence), AES-256-CBC with HMAC-SHA256, and the Python implementation must be validated against a real Intune tenant before Phase 5 export work begins. A known documentation error exists: STACK.md states "32-byte IV" but the actual AES-CBC standard IV is 16 bytes — use 16 bytes in the implementation.

Primary recommendation: Build the Docker scaffold and schema in parallel. Treat the .intunewin spike as a time-boxed investigation (max 2 days) that ends in a real Intune upload test — not just local file creation.


Standard Stack

Core

Library Version Purpose Why Standard
python:3.12-slim-bookworm 3.12 (Debian 12) Docker base image LTS Python, Debian glibc (not musl), slim keeps image under 200 MB, pre-built C-extension wheels always work
FastAPI 0.115.x HTTP framework Async-capable, Pydantic v2 validation, TemplateResponse, FileResponse, StreamingResponse built in
Uvicorn 0.30.x ASGI server FastAPI's recommended server; uvicorn[standard] pulls in uvloop + httptools
Jinja2 3.1.x HTML templating Ships with FastAPI's template support; used for both HTML pages and PS script generation
Peewee 3.17.x ORM for SQLite Sync-only ORM perfectly matched to SQLite single-writer model; create_tables() for schema auto-init
pycryptodome 3.20.x AES-256-CBC + HMAC-SHA256 Required for .intunewin inner package encryption; import as from Crypto.Cipher import AES
python-dotenv 1.0.x Env-var config Docker-level overrides without rebuilding (data dir, port, base URL)
python-multipart 0.0.9 Multipart file uploads Required by FastAPI's UploadFile; always install alongside FastAPI for file upload routes

Supporting (Phase 1 specific)

Library Version Purpose When to Use
Pico CSS 2.x Lightweight CSS framework Downloaded at image build time via curl; ~14 KB minified; supports OS dark/light via data-theme="auto"
HTMX 2.0.x Dynamic UI without SPA Downloaded at image build time; served as static file; handles partial page updates
Alpine.js 3.x Client-side UI state Downloaded at image build time; dropdowns, toggles, modals; no build step

Installation

# In Dockerfile (not requirements.txt — these are baked in at image build time)
# Frontend assets downloaded via curl during build:
# RUN curl -sLo /app/static/pico.min.css https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css
# RUN curl -sLo /app/static/htmx.min.js https://unpkg.com/htmx.org@2/dist/htmx.min.js
# RUN curl -sLo /app/static/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js

# requirements.txt (installed via pip in Dockerfile)
fastapi==0.115.*
uvicorn[standard]==0.30.*
jinja2==3.1.*
python-multipart==0.0.9
pycryptodome==3.20.*
python-dotenv==1.0.*
peewee==3.17.*

Architecture Patterns

imptune/
├── api/                        # HTTP route handlers (thin — delegate to services)
│   ├── __init__.py
│   ├── pages.py                # HTML page routes (SSR with Jinja2)
│   └── health.py               # GET /health — Docker healthcheck endpoint
├── services/                   # Domain logic (testable without HTTP context)
│   └── __init__.py
├── generators/                 # Format-specific builders
│   ├── __init__.py
│   └── intunewin_builder.py    # Phase 1 spike: Python .intunewin assembler
├── templates/                  # Jinja2 HTML templates
│   ├── base.html               # Layout with sidebar, static asset includes
│   └── dashboard.html          # Landing page (quick actions + recent activity)
├── db/
│   ├── __init__.py
│   ├── database.py             # Peewee database init, create_tables()
│   └── models.py               # ALL tables for phases 1-5 (full schema upfront)
├── storage/
│   └── driver_store.py         # Abstraction over /data/drivers volume path
├── static/                     # Served as /static/ — contains baked-in assets
│   ├── pico.min.css            # Downloaded at Docker build time
│   ├── htmx.min.js             # Downloaded at Docker build time
│   └── alpine.min.js           # Downloaded at Docker build time
├── config.py                   # Env-var driven configuration (DATA_DIR, PORT)
├── main.py                     # App entrypoint: create FastAPI, mount routes, StaticFiles
├── Dockerfile
├── docker-compose.yml
└── requirements.txt

Pattern 1: Docker Offline Asset Baking

What: Download CSS/JS assets with curl during docker build so they are baked into the image. No CDN access at container runtime.

When to use: Always — this is a hard requirement for air-gapped MSP networks.

Example Dockerfile snippet:

FROM python:3.12-slim-bookworm

WORKDIR /app

# Install system deps and download frontend assets in one layer
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && mkdir -p /app/static \
    && curl -sLo /app/static/pico.min.css \
       "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \
    && curl -sLo /app/static/htmx.min.js \
       "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
    && curl -sLo /app/static/alpine.min.js \
       "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \
    && apt-get purge -y curl && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose.yml:

services:
  imptune:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - imptune_data:/data
    restart: unless-stopped
    environment:
      - DATA_DIR=/data

volumes:
  imptune_data:

Pattern 2: Peewee Schema Auto-Init

What: Define all tables (all phases) in models.py, auto-create on startup via create_tables(safe=True).

When to use: On every container start — safe=True is idempotent (no-op if tables already exist).

Example:

# db/database.py
from peewee import SqliteDatabase
import os

DB_PATH = os.environ.get("DATA_DIR", "/data") + "/imptune.db"
db = SqliteDatabase(DB_PATH, pragmas={"journal_mode": "wal", "foreign_keys": 1})

def init_db():
    from db.models import Driver, Printer, Client, Icon
    db.connect(reuse_if_open=True)
    db.create_tables([Driver, Printer, Client, Icon], safe=True)
# db/models.py
from peewee import *
from db.database import db
import datetime

class BaseModel(Model):
    class Meta:
        database = db

class Client(BaseModel):
    name = CharField(unique=True)
    created_at = DateTimeField(default=datetime.datetime.utcnow)

class Driver(BaseModel):
    sha256 = CharField(unique=True, index=True)  # content-addressed key
    original_filename = CharField()
    size_bytes = IntegerField()
    uploaded_at = DateTimeField(default=datetime.datetime.utcnow)
    # Phase 2 fields (populated during INF parsing):
    driver_desc = CharField(null=True)   # parsed DriverDesc from INF
    inf_filename = CharField(null=True)  # which INF file inside the ZIP
    architecture = CharField(null=True)  # x64, x86, arm64
    has_cat_file = BooleanField(default=False)

class Printer(BaseModel):
    name = CharField()
    ip_address = CharField()
    port_name = CharField()
    client = ForeignKeyField(Client, backref="printers", null=True)
    driver = ForeignKeyField(Driver, backref="printers", null=True)
    duplex_mode = CharField(default="OneSided")  # OneSided|TwoSidedLongEdge|TwoSidedShortEdge
    color_mode = BooleanField(default=True)
    paper_size = CharField(default="A4")
    collate = BooleanField(default=True)
    created_at = DateTimeField(default=datetime.datetime.utcnow)
    updated_at = DateTimeField(default=datetime.datetime.utcnow)

class Icon(BaseModel):
    printer = ForeignKeyField(Printer, backref="icons", unique=True)
    sha256 = CharField()
    original_filename = CharField()
    size_bytes = IntegerField()
    uploaded_at = DateTimeField(default=datetime.datetime.utcnow)

Pattern 3: .intunewin File Assembly (Python-Native)

What: Assemble a valid .intunewin file in Python without IntuneWinAppUtil.exe.

Verified byte layout (from svrooij.io decryption article):

Encrypted blob layout:
  [0:32]   — HMAC-SHA256 of the ciphertext (32 bytes)
  [32:48]  — AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
  [48:]    — AES-256-CBC ciphertext (padded to 16-byte boundary)

IMPORTANT: The IV is 16 bytes, not 32. STACK.md has a documentation error on this point.

Detection.xml schema:

<ApplicationInfo xmlns="http://schemas.microsoft.com/IntuneWin">
  <Name>install.ps1</Name>
  <UnencryptedContentSize>12345</UnencryptedContentSize>
  <FileName>IntunePackage.intunewin</FileName>
  <SetupFile>install.ps1</SetupFile>
  <EncryptionInfo>
    <EncryptionKey>base64(32-byte AES key)</EncryptionKey>
    <MacKey>base64(32-byte HMAC key)</MacKey>
    <InitializationVector>base64(16-byte IV)</InitializationVector>
    <Mac>base64(32-byte HMAC-SHA256)</Mac>
    <MacAlgorithm>SHA256</MacAlgorithm>
    <ProfileIdentifier>ProfileVersion1</ProfileIdentifier>
    <FileDigest>base64(SHA256 of plaintext ZIP)</FileDigest>
    <FileDigestAlgorithm>SHA256</FileDigestAlgorithm>
  </EncryptionInfo>
</ApplicationInfo>

Outer ZIP structure:

IntuneWinPackage/
├── Contents/
│   └── IntunePackage.intunewin    ← the encrypted blob
└── Metadata/
    └── Detection.xml              ← encryption metadata

Python assembly skeleton:

# generators/intunewin_builder.py
import os, io, base64, hashlib, hmac, zipfile
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from xml.etree.ElementTree import Element, SubElement, tostring
import xml.dom.minidom

def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
    """Build a .intunewin file from source_dir, with setup_file as entry point."""

    # Step 1: Create inner ZIP (DEFLATE-compressed content)
    inner_zip_buf = io.BytesIO()
    with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for root, dirs, files in os.walk(source_dir):
            for file in files:
                abs_path = os.path.join(root, file)
                arc_name = os.path.relpath(abs_path, source_dir)
                zf.write(abs_path, arc_name)
    plaintext = inner_zip_buf.getvalue()

    # Step 2: Encrypt with AES-256-CBC
    aes_key = os.urandom(32)       # 32-byte AES key
    mac_key = os.urandom(32)       # 32-byte HMAC key
    iv = os.urandom(16)            # 16-byte IV (standard AES block size)
    cipher = AES.new(aes_key, AES.MODE_CBC, iv)
    ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))

    # Step 3: Compute HMAC-SHA256 over ciphertext
    mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()

    # Step 4: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext]
    encrypted_blob = mac + iv + ciphertext

    # Step 5: Compute plaintext digest for Detection.xml
    file_digest = hashlib.sha256(plaintext).digest()

    # Step 6: Build Detection.xml
    app_info = Element("ApplicationInfo",
        xmlns="http://schemas.microsoft.com/IntuneWin")
    SubElement(app_info, "Name").text = setup_file
    SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
    SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
    SubElement(app_info, "SetupFile").text = setup_file
    enc = SubElement(app_info, "EncryptionInfo")
    SubElement(enc, "EncryptionKey").text = base64.b64encode(aes_key).decode()
    SubElement(enc, "MacKey").text = base64.b64encode(mac_key).decode()
    SubElement(enc, "InitializationVector").text = base64.b64encode(iv).decode()
    SubElement(enc, "Mac").text = base64.b64encode(mac).decode()
    SubElement(enc, "MacAlgorithm").text = "SHA256"
    SubElement(enc, "ProfileIdentifier").text = "ProfileVersion1"
    SubElement(enc, "FileDigest").text = base64.b64encode(file_digest).decode()
    SubElement(enc, "FileDigestAlgorithm").text = "SHA256"
    detection_xml = xml.dom.minidom.parseString(tostring(app_info)).toprettyxml()

    # Step 7: Build outer ZIP (STORED — no extra compression on encrypted content)
    with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
        outer.writestr("IntuneWinPackage/Contents/IntunePackage.intunewin",
                       encrypted_blob)
        outer.writestr("IntuneWinPackage/Metadata/Detection.xml",
                       detection_xml)

Source: svrooij.io decryption article (verified format), volodymyrsmirnov/IntuneWin C# reference (structure verified)

Anti-Patterns to Avoid

  • Alpine Linux base image: musl libc breaks pycryptodome and other C-extension wheels; use python:3.12-slim-bookworm only.
  • Downloading assets at container runtime: Never use CDN links in HTML templates; all assets must be served from /app/static/ which is baked into the image.
  • Tailwind CDN Play script in templates: Per Tailwind docs, Play CDN is development-only. The locked decision already chooses Pico CSS — a pre-built static file that needs no CDN at runtime.
  • Storing the SQLite file inside the container filesystem: Always mount /data as a named volume; SQLite must persist across container restarts.
  • peewee.database.connect() without WAL mode: SQLite default journal mode is DELETE; enable WAL ("journal_mode": "wal") so reads don't block writes during generation.
  • 32-byte IV in .intunewin: Standard AES-CBC IV is 16 bytes (AES block size). Using 32 bytes will produce a non-compliant file that Intune will reject. The STACK.md documentation has this wrong — use 16 bytes.

Don't Hand-Roll

Problem Don't Build Use Instead Why
AES-256-CBC encryption Custom AES implementation pycryptodome (from Crypto.Cipher import AES) Padding edge cases, IV handling, block alignment — stdlib hashlib does not provide AES
HMAC-SHA256 Custom HMAC Python stdlib hmac.new(key, data, hashlib.sha256) Already in stdlib, correct constant-time comparison built in
SQLite schema management Raw CREATE TABLE IF NOT EXISTS strings Peewee create_tables(safe=True) Migration safety, model-to-SQL mapping, foreign key management
Serving static files in FastAPI Custom file-serving route app.mount("/static", StaticFiles(directory="static")) FastAPI's built-in StaticFiles handles ETags, range requests, content-type detection
Docker healthcheck HTTP request curl (which may not be in final image) Python one-liner: python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" Uses stdlib; no curl dependency in the slim image

Key insight: pycryptodome handles all the crypto complexity. The hard part of the .intunewin spike is not the encryption itself — it's assembling the exact byte layout Intune expects and validating the output against a real tenant.


Common Pitfalls

Pitfall 1: Wrong IV Size in .intunewin (CRITICAL)

What goes wrong: Using a 32-byte IV instead of the correct 16-byte AES block size. The encrypted blob format is [HMAC-SHA256 (32 bytes)] + [IV (16 bytes)] + [ciphertext]. The total overhead is 48 bytes, not 64. Files built with a 32-byte IV will fail to decrypt on the Intune side.

Why it happens: STACK.md states "32-byte IV" — this is a documentation error. The decryption article confirms 16 bytes via .NET's aes.IV.Length (which is always 16 for AES).

How to avoid: Always use iv = os.urandom(16) and AES.new(key, AES.MODE_CBC, iv) where len(iv) == 16.

Warning signs: ValueError: IV must be 16 bytes long from pycryptodome if you use 32.

Pitfall 2: Storing State in Container Filesystem

What goes wrong: Writing the SQLite file or driver ZIPs to /app/ or /tmp/. Data disappears on container restart.

Why it happens: Default working directory in Docker is the app folder; developers forget to configure the volume.

How to avoid: Set DATA_DIR=/data env var. docker-compose.yml mounts imptune_data:/data. SQLite path must derive from DATA_DIR. Driver files go to DATA_DIR/drivers/. Never write persistent data outside the volume mount.

Warning signs: Fresh database on every docker compose restart.

Pitfall 3: Alpine Base Image Breaking pycryptodome

What goes wrong: Using python:3.12-alpine as the Docker base. pycryptodome requires C extensions; the pre-built wheels target glibc, not Alpine's musl libc. pip will try to compile from source (requiring gcc/musl-dev) and often fails silently or produces a broken install.

Why it happens: Alpine is smaller, so it seems attractive for Docker images.

How to avoid: Use python:3.12-slim-bookworm (Debian 12). The final image will be slightly larger (~150-200 MB vs ~80 MB for Alpine) but will reliably install all C-extension packages.

Pitfall 4: CDN Assets Requested at Runtime

What goes wrong: HTML templates reference <link rel="stylesheet" href="https://cdn.jsdelivr.net/...">. The container starts but the browser gets no CSS/JS when running on an air-gapped network.

Why it happens: Developers test on internet-connected machines where CDN works; the failure only manifests on offline deployments.

How to avoid: All <link> and <script> tags in templates must reference /static/... paths. The curl downloads in the Dockerfile must complete successfully — add --fail flag to curl so the build fails if a download fails rather than producing an empty file.

Warning signs: curl in Dockerfile without --fail; template contains jsdelivr.net, unpkg.com, or cdnjs.com URLs.

Pitfall 5: .intunewin Spike Validated Only Locally

What goes wrong: The spike "works" because the developer verifies the file structure locally (zip contents, XML fields look right) but never uploads to a real Intune tenant. The actual validation — Intune accepting the file and successfully deploying it — is skipped. Phase 5 export is then built on an unvalidated format assumption.

Why it happens: Intune tenant access may not be immediately available; local file inspection seems sufficient.

How to avoid: The spike's only valid success criterion is: "file uploaded to a real Intune tenant, application shows as successfully uploaded (no format error)." Create a simple test package (one small file) and upload it. This validates the format; full driver packaging validation comes in Phase 5.

Warning signs: Spike task marked done without a real Intune upload test result.

Pitfall 6: Peewee Called from FastAPI Async Context Without Executor

What goes wrong: Calling Peewee's synchronous ORM methods directly from a FastAPI async def route causes blocking of the event loop.

Why it happens: FastAPI encourages async def routes; Peewee is sync-only.

How to avoid for Phase 1: Use regular def (not async def) for FastAPI route handlers that touch the database. FastAPI runs sync handlers in a thread pool automatically. This is the correct pattern for Peewee + FastAPI — do not fight it with run_in_executor.

# Correct: sync handler — FastAPI runs this in a thread pool
@router.get("/")
def dashboard():
    recent = list(Printer.select().order_by(Printer.updated_at.desc()).limit(5))
    return templates.TemplateResponse("dashboard.html", {"request": request, "printers": recent})

# Wrong for Phase 1: async handler calling sync Peewee
@router.get("/")
async def dashboard():
    recent = list(Printer.select()...)  # blocks the event loop

Code Examples

FastAPI App Entrypoint with Static Files and Templates

# main.py
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from db.database import init_db
from api import pages

app = FastAPI(title="ImpTune")

# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
app.mount("/static", StaticFiles(directory="static"), name="static")

# Register page routes
app.include_router(pages.router)

@app.on_event("startup")
def on_startup():
    init_db()  # creates all tables on first run, no-op if they exist

Pico CSS Dark/Light Theme (OS preference)

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ImpTune</title>
  <link rel="stylesheet" href="/static/pico.min.css">
  <script defer src="/static/alpine.min.js"></script>
  <script src="/static/htmx.min.js"></script>
</head>
<body>
  <div style="display:flex">
    <nav><!-- sidebar --></nav>
    <main class="container">{% block content %}{% endblock %}</main>
  </div>
</body>
</html>

data-theme="auto" instructs Pico CSS to follow the OS prefers-color-scheme media query automatically. No JavaScript needed.

Docker healthcheck without curl

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

FastAPI health endpoint

# api/health.py
from fastapi import APIRouter
router = APIRouter()

@router.get("/health")
def health():
    return {"status": "ok"}

State of the Art

Old Approach Current Approach When Changed Impact
IntuneWinAppUtil.exe (Windows-only binary) Python-native .intunewin (zipfile + pycryptodome) 2023 — svrooij reverse-engineered format Linux containers can now generate .intunewin without Wine or Windows base
Tailwind CDN Play in templates Pre-built CSS framework (Pico CSS) served as static file Phase 1 decision (2026-04-10) Zero CDN dependency; air-gapped compatible
Gunicorn + Flask Uvicorn + FastAPI 2022-2024 ecosystem shift Async-capable, Pydantic validation built in, less boilerplate
SQLAlchemy async Peewee sync Phase 1 decision No async overhead for SQLite single-writer; simpler code

Deprecated/outdated:

  • pycrypto: Unmaintained since 2012, known CVEs. Use pycryptodome (maintained drop-in, from Crypto.Cipher import AES).
  • Tailwind Play CDN: Explicitly marked as development-only by Tailwind docs. Not suitable for production or air-gapped environments.
  • Alpine Linux base image: Avoid for any Python project using C-extension packages (pycryptodome, lxml, etc.).

Open Questions

  1. Inner ZIP compression method (DEFLATE vs STORED)

    • What we know: The outer ZIP uses ZIP_STORED; the inner ZIP content appears to use DEFLATE (C# default, volodymyrsmirnov/IntuneWin uses default .NET ZipArchive). STACK.md says DEFLATE.
    • What's unclear: Whether Intune requires DEFLATE specifically or accepts ZIP_STORED for the inner package. One search result stated "no compression used" which conflicts with STACK.md.
    • Recommendation: Implement with ZIP_DEFLATED first (matches the reference implementation behavior). If Intune rejects it, try ZIP_STORED. The real Intune upload test in the spike will resolve this definitively.
  2. MacKey vs EncryptionKey sizes

    • What we know: EncryptionKey is 32 bytes (256-bit AES). MacKey is also described as a separate key for HMAC-SHA256. Standard HMAC-SHA256 can use any key size (SHA-256 block size is 64 bytes, but 32 bytes is common).
    • What's unclear: Whether MacKey must be exactly 32 bytes or can differ. The svrooij articles don't state the MacKey size explicitly.
    • Recommendation: Use mac_key = os.urandom(32) (32 bytes) — same size as the AES key, consistent with svrooij ContentPrep behavior.
  3. Pico CSS v2 sidebar layout

    • What we know: Pico CSS v2 is a classless/minimal framework with a container component and grid support. It does not have a built-in sidebar layout.
    • What's unclear: Whether additional CSS will be needed for the persistent sidebar, or if Pico's grid/flex utilities suffice.
    • Recommendation: Add a small app.css static file alongside pico.min.css for layout overrides (sidebar width, flex container). Keep it under 50 lines. This is Claude's discretion per CONTEXT.md.

Validation Architecture

Test Framework

Property Value
Framework pytest (to be installed in Wave 0)
Config file None — see Wave 0
Quick run command pytest tests/ -x -q
Full suite command pytest tests/ -v

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
INFRA-01 Container starts and returns HTTP 200 on GET /health smoke pytest tests/test_health.py -x Wave 0
INFRA-01 SQLite database initializes with correct tables on first run unit pytest tests/test_db.py::test_create_tables -x Wave 0
INFRA-02 No Node.js process or external DB in running container manual docker inspect imptune | grep node (manual check) manual-only
INFRA-02 All static assets served from /static/ (no CDN URLs in HTML) unit pytest tests/test_static.py::test_no_cdn_urls -x Wave 0
(spike) .intunewin file has correct byte layout (HMAC+IV+ciphertext) unit pytest tests/test_intunewin.py::test_byte_layout -x Wave 0
(spike) .intunewin uploads successfully to real Intune tenant manual Upload test — manual, requires Intune access manual-only

Sampling Rate

  • Per task commit: pytest tests/ -x -q
  • Per wave merge: pytest tests/ -v
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • tests/__init__.py — package marker
  • tests/conftest.py — shared fixtures (temp dir, test DB path)
  • tests/test_health.py — covers INFRA-01 HTTP health check
  • tests/test_db.py — covers INFRA-01 schema init (all tables created, WAL mode enabled)
  • tests/test_static.py — covers INFRA-02 no-CDN-URLs assertion (scan templates)
  • tests/test_intunewin.py — covers spike byte layout validation
  • Framework install: pip install pytest — add to requirements-dev.txt

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

  • volodymyrsmirnov/IntuneWin (GitHub) — C# reference implementation; confirmed DEFLATE for inner ZIP (default .NET behavior)
  • SvRooij.ContentPrep NuGet 0.4.2 (2025-10-03) — cross-platform validation that the format is stable and reimplementable
  • STACK.md (project research, 2026-04-10) — stack decisions; NOTE: IV size stated as 32 bytes is incorrect, should be 16

Tertiary (LOW confidence)

  • WebSearch result claiming "no compression used" for inner ZIP — conflicts with STACK.md and .NET default behavior; needs spike to resolve

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all libraries verified against official docs; versions confirmed compatible
  • Docker scaffold pattern: HIGH — standard FastAPI Docker deployment, well-documented
  • Peewee schema pattern: HIGH — official Peewee docs, straightforward sync ORM usage
  • .intunewin format: MEDIUM — format confirmed by reverse-engineering; IV size corrected (16 bytes); inner ZIP compression TBD; must validate against real Intune tenant
  • Pitfalls: HIGH — all pitfalls derived from verified sources or official documentation

Research date: 2026-04-10 Valid until: 2026-05-10 (stable ecosystem; .intunewin format validity: confirm during spike)

Critical correction flagged: STACK.md states "32-byte IV" for .intunewin encryption. Multiple sources (AES standard, svrooij.io decryption article citing .NET aes.IV.Length = 16) confirm the IV is 16 bytes. The planner must use 16 bytes in the spike implementation.