rebreak-monorepo/docs/superpowers/plans/2026-06-18-mdm-health-check.md
chahinebrini 2919ce45b8 feat(magic): sync current ReBreak Magic app state
Include recent Magic app work: Tauri native shell, iOS device detection
via supervise-magic sidecar, MDM client, local HTTP server, new pages
(detect, enroll, supervise, sideload, pair, preflight, configure, done),
and updated device section/status UI.
2026-06-18 05:23:26 +02:00

14 KiB

MDM Healthcheck Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ein Nitro-Cron-Healthcheck prüft alle 5 Minuten alle über UserDevice.mdmId mit NanoMDM verknüpften iOS-Geräte und persistiert Enrollment-, Supervision-Status sowie letzten Check-In auf UserDevice.

Architecture: Ein neues Nitro-Plugin orchestriert den Lauf. backend/server/db/mdm.ts bekommt Bulk-Lesefunktionen für NanoMDM und ein Update für UserDevice. Die benötigten Spalten werden per Prisma-Migration auf UserDevice ergänzt. Keine neue Tabelle.

Tech Stack: Nuxt/Nitro, Prisma, PostgreSQL (NanoMDM), TypeScript, Vitest.


Files

  • backend/prisma/schema.prismaUserDevice um mdmEnrolled, mdmSupervised, mdmLastSeenAt erweitern
  • backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql — idempotente Migration
  • backend/server/db/mdm.ts — Bulk-Abfrage von NanoMDM, Update der UserDevice-Health-Spalten
  • backend/server/plugins/mdm-health-cron.ts — 5-Minuten-Cron
  • backend/tests/devices/mdm-health.test.ts — Unit-Tests für DB-Layer und Cron-Logik

Task 1: Extend UserDevice Prisma schema

Files:

  • Modify: backend/prisma/schema.prisma:1111-1114

  • Step 1: Add three new fields after mdmId

    // ─── NanoMDM iOS Enrollment ─────────────────────────────────────────────
    /// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E).
    /// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft.
    mdmId String? @map("mdm_id")

    /// Gespiegelter Enrollment-Status aus NanoMDM enrollments.enabled.
    mdmEnrolled Boolean? @map("mdm_enrolled")

    /// Gespiegelter Supervision-Status aus NanoMDM devices.unlock_token IS NOT NULL.
    mdmSupervised Boolean? @map("mdm_supervised")

    /// Letzter NanoMDM Check-In (enrollments.last_seen_at).
    mdmLastSeenAt DateTime? @map("mdm_last_seen_at") @db.Timestamptz(6)
  • Step 2: Validate schema

Run: cd backend && pnpm prisma validate Expected: Prisma schema validation - (get config )

  • Step 3: Commit
git add backend/prisma/schema.prisma
git commit -m "feat(mdm): add mdmEnrolled, mdmSupervised, mdmLastSeenAt to UserDevice schema"

Task 2: Create migration for MDM health columns

Files:

  • Create: backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql

  • Step 1: Write idempotent migration

-- Spiegel-Spalten für NanoMDM Enrollment/Supervision-Status auf UserDevice.
-- mdm_id wurde manuell hinzugefügt; IF NOT EXISTS macht die Migration idempotent.

ALTER TABLE "rebreak"."user_devices"
    ADD COLUMN IF NOT EXISTS "mdm_id" TEXT,
    ADD COLUMN IF NOT EXISTS "mdm_enrolled" BOOLEAN,
    ADD COLUMN IF NOT EXISTS "mdm_supervised" BOOLEAN,
    ADD COLUMN IF NOT EXISTS "mdm_last_seen_at" TIMESTAMPTZ(6);

CREATE INDEX IF NOT EXISTS "user_devices_mdm_id_idx"
    ON "rebreak"."user_devices"("mdm_id");
  • Step 2: Apply migration locally

Run: cd backend && pnpm prisma migrate dev --name add_mdm_health_columns Expected: Migration applies successfully; Prisma Client is regenerated.

  • Step 3: Commit
git add backend/prisma/migrations/20250618_add_mdm_health_columns
git commit -m "feat(mdm): add migration for UserDevice MDM health columns"

Task 3: Extend mdm.ts DB layer

Files:

  • Modify: backend/server/db/mdm.ts

  • Step 1: Add types and select constant after MdmDeviceStatus

export interface MdmEnrollmentStatus {
  enrolled: boolean;
  supervised: boolean;
  lastSeenAt: Date | null;
}

export interface UserDeviceMdmHealthRecord {
  id: string;
  userId: string;
  deviceId: string;
  platform: string;
  mdmId: string;
  mdmEnrolled: boolean | null;
  mdmSupervised: boolean | null;
  mdmLastSeenAt: Date | null;
}

const USER_DEVICE_MDM_HEALTH_SELECT = {
  id: true,
  userId: true,
  deviceId: true,
  platform: true,
  mdmId: true,
  mdmEnrolled: true,
  mdmSupervised: true,
  mdmLastSeenAt: true,
} as const;
  • Step 2: Add getLinkedUserDevices
/**
 * Load all iOS devices that have a NanoMDM UDID link.
 */
export async function getLinkedUserDevices(): Promise<UserDeviceMdmHealthRecord[]> {
  const db = usePrisma();
  return db.userDevice.findMany({
    where: { platform: "ios", mdmId: { not: null } },
    select: USER_DEVICE_MDM_HEALTH_SELECT,
  }) as Promise<UserDeviceMdmHealthRecord[]>;
}
  • Step 3: Add getMdmEnrollmentStatusesByUdids
/**
 * Bulk-query NanoMDM for enrollment/supervision/last-seen status.
 * Returns a map keyed by UDID. Missing devices are omitted.
 */
export async function getMdmEnrollmentStatusesByUdids(
  udids: string[],
): Promise<Map<string, MdmEnrollmentStatus>> {
  if (udids.length === 0) {
    return new Map();
  }

  const pool = useMdmPool();
  const result = await pool.query<{
    udid: string;
    enrolled: boolean;
    supervised: boolean;
    last_seen_at: Date | null;
  }>(
    `SELECT
       d.id AS udid,
       (e.enabled = TRUE) AS enrolled,
       (d.unlock_token IS NOT NULL) AS supervised,
       e.last_seen_at
     FROM devices d
     LEFT JOIN enrollments e ON e.device_id = d.id
     WHERE d.id = ANY($1::text[])`,
    [udids],
  );

  const map = new Map<string, MdmEnrollmentStatus>();
  for (const row of result.rows) {
    map.set(row.udid, {
      enrolled: row.enrolled,
      supervised: row.supervised,
      lastSeenAt: row.last_seen_at,
    });
  }
  return map;
}
  • Step 4: Add updateUserDeviceMdmHealth
/**
 * Persist mirrored MDM health status on a UserDevice row.
 */
export async function updateUserDeviceMdmHealth(
  id: string,
  status: MdmEnrollmentStatus,
): Promise<void> {
  const db = usePrisma();
  await db.userDevice.update({
    where: { id },
    data: {
      mdmEnrolled: status.enrolled,
      mdmSupervised: status.supervised,
      mdmLastSeenAt: status.lastSeenAt,
    },
  });
}
  • Step 5: Run typecheck

Run: cd backend && pnpm typecheck Expected: No type errors.

  • Step 6: Commit
git add backend/server/db/mdm.ts
git commit -m "feat(mdm): add bulk MDM health status read/write helpers"

Task 4: Create mdm-health-cron.ts plugin

Files:

  • Create: backend/server/plugins/mdm-health-cron.ts

  • Step 1: Write the cron plugin

/**
 * MDM Healthcheck Cron
 *
 * Läuft alle 5 Minuten. Prüft für alle mit NanoMDM verknüpften iOS-Geräte
 * den aktuellen Enrollment-/Supervision-Status und spiegelt ihn auf UserDevice.
 */
import { consola } from "consola";
import {
  getLinkedUserDevices,
  getMdmEnrollmentStatusesByUdids,
  updateUserDeviceMdmHealth,
  type MdmEnrollmentStatus,
} from "../db/mdm";

const FIVE_MINUTES = 5 * 60 * 1000;
const INITIAL_DELAY_MS = 30 * 1000;

export default defineNitroPlugin((nitro) => {
  if (import.meta.dev) {
    consola.info("[mdm-health-cron] Skipping cron in dev mode");
    return;
  }

  consola.info("[mdm-health-cron] Starting (5min interval)");

  const initialTimer = setTimeout(() => {
    runMdmHealthCheck().catch(() => {});
  }, INITIAL_DELAY_MS);

  const interval = setInterval(() => {
    runMdmHealthCheck().catch(() => {});
  }, FIVE_MINUTES);

  nitro.hooks.hook("close", () => {
    clearTimeout(initialTimer);
    clearInterval(interval);
  });
});

async function runMdmHealthCheck() {
  const start = Date.now();
  try {
    const devices = await getLinkedUserDevices();
    if (devices.length === 0) {
      consola.info("[mdm-health-cron] No linked iOS devices");
      return;
    }

    const statuses = await getMdmEnrollmentStatusesByUdids(
      devices.map((d) => d.mdmId),
    );

    let updated = 0;
    let unchanged = 0;

    for (const device of devices) {
      const status = statuses.get(device.mdmId) ?? {
        enrolled: false,
        supervised: false,
        lastSeenAt: null,
      };

      const changed =
        device.mdmEnrolled !== status.enrolled ||
        device.mdmSupervised !== status.supervised ||
        !sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt);

      if (changed) {
        await updateUserDeviceMdmHealth(device.id, status);
        updated++;
      } else {
        unchanged++;
      }
    }

    consola.success(
      `[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged)`,
    );
  } catch (err: any) {
    consola.error("[mdm-health-cron] run failed:", err?.message ?? err);
  }
}

function sameNullableDate(a: Date | null, b: Date | null): boolean {
  if (a === null && b === null) return true;
  if (a === null || b === null) return false;
  return a.getTime() === b.getTime();
}
  • Step 2: Typecheck

Run: cd backend && pnpm typecheck Expected: No errors.

  • Step 3: Commit
git add backend/server/plugins/mdm-health-cron.ts
git commit -m "feat(mdm): add 5-minute MDM healthcheck cron"

Task 5: Write tests for MDM healthcheck

Files:

  • Create: backend/tests/devices/mdm-health.test.ts

  • Step 1: Write tests for DB helpers

/**
 * Tests für MDM-Healthcheck DB-Layer.
 */
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
  getLinkedUserDevices,
  getMdmEnrollmentStatusesByUdids,
  updateUserDeviceMdmHealth,
} from "../../server/db/mdm";

const mockPrisma = {
  userDevice: {
    findMany: vi.fn(),
    update: vi.fn(),
  },
};

vi.mock("../../server/utils/prisma", () => ({
  usePrisma: () => mockPrisma,
}));

const mockPool = {
  query: vi.fn(),
};

vi.mock("pg", () => ({
  default: { Pool: vi.fn(() => mockPool) },
  Pool: vi.fn(() => mockPool),
}));

vi.mock("../../server/utils/runtime-config", () => ({
  useRuntimeConfig: () => ({ mdmDatabaseUrl: "postgres://fake" }),
}));

describe("getLinkedUserDevices", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("returns only iOS devices with a non-null mdmId", async () => {
    mockPrisma.userDevice.findMany.mockResolvedValue([
      { id: "d1", userId: "u1", deviceId: "cap1", platform: "ios", mdmId: "udid-1", mdmEnrolled: true, mdmSupervised: true, mdmLastSeenAt: null },
    ]);

    const result = await getLinkedUserDevices();

    expect(result).toHaveLength(1);
    expect(result[0].mdmId).toBe("udid-1");
    expect(mockPrisma.userDevice.findMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { platform: "ios", mdmId: { not: null } },
      }),
    );
  });
});

describe("getMdmEnrollmentStatusesByUdids", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("returns an empty map for empty input", async () => {
    const result = await getMdmEnrollmentStatusesByUdids([]);
    expect(result.size).toBe(0);
    expect(mockPool.query).not.toHaveBeenCalled();
  });

  it("maps NanoMDM rows to status objects", async () => {
    const lastSeen = new Date("2026-06-15T11:25:00Z");
    mockPool.query.mockResolvedValue({
      rows: [
        { udid: "udid-1", enrolled: true, supervised: true, last_seen_at: lastSeen },
        { udid: "udid-2", enrolled: false, supervised: true, last_seen_at: null },
      ],
    });

    const result = await getMdmEnrollmentStatusesByUdids(["udid-1", "udid-2"]);

    expect(result.get("udid-1")).toEqual({ enrolled: true, supervised: true, lastSeenAt: lastSeen });
    expect(result.get("udid-2")).toEqual({ enrolled: false, supervised: true, lastSeenAt: null });
  });
});

describe("updateUserDeviceMdmHealth", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("updates the mirrored status columns", async () => {
    mockPrisma.userDevice.update.mockResolvedValue({ id: "d1" });

    await updateUserDeviceMdmHealth("d1", {
      enrolled: true,
      supervised: false,
      lastSeenAt: new Date("2026-06-15T11:25:00Z"),
    });

    expect(mockPrisma.userDevice.update).toHaveBeenCalledWith({
      where: { id: "d1" },
      data: {
        mdmEnrolled: true,
        mdmSupervised: false,
        mdmLastSeenAt: new Date("2026-06-15T11:25:00Z"),
      },
    });
  });
});
  • Step 2: Run tests

Run: cd backend && pnpm test backend/tests/devices/mdm-health.test.ts Expected: All tests pass.

  • Step 3: Commit
git add backend/tests/devices/mdm-health.test.ts
git commit -m "test(mdm): add MDM healthcheck DB-layer tests"

Task 6: Apply migration and verify

  • Step 1: Apply migration to the target database

Run: cd backend && pnpm prisma migrate deploy Expected: Migration 20250618_add_mdm_health_columns applies successfully.

  • Step 2: Regenerate Prisma Client

Run: cd backend && pnpm prisma generate Expected: Client generated.

  • Step 3: Run full test suite (at least device tests)

Run: cd backend && pnpm test backend/tests/devices/ Expected: All tests pass.

  • Step 4: Commit
git add backend/prisma/schema.prisma backend/prisma/migrations backend/server/db/mdm.ts backend/server/plugins/mdm-health-cron.ts backend/tests/devices/mdm-health.test.ts
git commit -m "feat(mdm): implement MDM healthcheck cron with mirrored status columns"

Self-Review

Spec coverage:

  • Healthcheck runs every 5 minutes → Task 4.
  • Supervised devices checked → Task 3 query filters via unlock_token IS NOT NULL.
  • Enrollment status changes persisted → Task 3 updateUserDeviceMdmHealth.
  • No new table → only UserDevice columns added → Tasks 1-2.

Placeholder scan:

  • No TBD/TODO/fill-in-details.
  • All code blocks contain concrete implementation.

Type consistency:

  • MdmEnrollmentStatus used consistently across Tasks 3, 4, 5.
  • Column names mdmEnrolled, mdmSupervised, mdmLastSeenAt match in schema, migration, DB layer, tests.