feat(import): Apple Health export.zip endpoints + multipart streamer

Three new routes wire the streaming parser to the wire:

* `POST /api/import/apple-health-export` — user-facing kick-off.
  Streams the multipart upload straight to `/tmp` (no in-memory
  buffer for the 1.5 GB hard cap), hashes the bytes inline for
  content-based idempotency, creates an `ImportJob` row in
  `queued`, and enqueues the `apple-health-import` pg-boss job.
  Returns `202 { jobId }` so the client can poll for progress.
* `GET /api/import/apple-health-export/[jobId]/status` — polling
  endpoint returning the canonical `ImportJobStatusResponse`
  envelope (R-1 §8). Authorises on owner-id OR triggering admin.
* `POST /api/admin/import-apple-health-export` — admin variant
  (cookie-only via `requireAdmin()`; Bearer never elevates) with
  a `userId` text field naming the target user. Same content-hash
  idempotency scoped per target user.

The kick-off path uses a hand-rolled multipart boundary walker
(`src/lib/multipart/stream-to-disk.ts`) so a multi-gigabyte
`export.zip` upload never lands in V8 heap — Next.js's
`request.formData()` buffers the full body. Captured bytes
stream to disk while a SHA-256 hash accumulates in the same pass.

Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3, §5.4,
§5.5, §9.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:35:12 +02:00
1 parent 9a8a9e9ab2
commit 73cd44e009
4 files changed
+734

No files matched your search

@@ -0,0 +1,192 @@
/**
* `POST /api/admin/import-apple-health-export` — admin-only Apple
* Health import on behalf of a target user.
*
* Same multipart streaming path as the user-facing kick-off, but with
* `requireAdmin()` (cookie-only — Bearer tokens never elevate) and a
* `userId` text field naming the target user. The kicked-off
* `ImportJob` row carries `triggeredByAdminId = admin.id` so the
* status endpoint admits both the target user AND the triggering
* admin.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §9.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
export const dynamic = "force-dynamic";
const MAX_UPLOAD_BYTES = 1.5 * 1024 * 1024 * 1024;
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 60_000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
annotate({ action: { name: "admin.import-apple-health.kickoff" } });
const rl = await checkRateLimit(
`admin-import-apple-health:${admin.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
throw new HttpError(429, "Too many import uploads, try again later");
}
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
if (declaredBytes > MAX_UPLOAD_BYTES) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
reason: "content_length_exceeded",
contentLength: declaredBytes,
},
});
return apiError("Upload exceeds 1.5 GB limit", 413);
}
const body = request.body;
if (!body) {
return apiError("Request body is required", 400);
}
let uploaded;
try {
uploaded = await streamMultipartToDisk(
body,
request.headers.get("content-type"),
{
maxBytes: MAX_UPLOAD_BYTES,
fieldName: "file",
tmpPrefix: "healthlog-admin-apple-health-import",
},
);
} catch (err) {
const message = err instanceof Error ? err.message : "upload_failed";
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "stream_to_disk_failed", message },
});
return apiError(`Multipart upload failed: ${message}`, 422);
}
// Resolve the target user from the multipart text field. Mandatory:
// the admin variant is explicitly cross-user.
const targetUserId = uploaded.textFields.userId;
if (!targetUserId) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "missing_user_id" },
});
return apiError("Multipart field 'userId' is required", 422);
}
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, username: true },
});
if (!targetUser) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
return apiError(
`Target user '${targetUserId}' does not exist`,
422,
);
}
// Same content-hash idempotency as the user-facing route, but scoped
// by the target user — an admin re-uploading the same file for the
// same user resolves to the previous job.
const existing = await prisma.importJob.findFirst({
where: {
userId: targetUser.id,
uploadSha256: uploaded.sha256,
},
orderBy: { startedAt: "desc" },
});
if (existing) {
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
jobId: existing.id,
status: existing.status,
idempotent: true,
},
202,
);
}
const boss = getGlobalBoss();
if (!boss) {
await auditLog("admin.import-apple-health.kickoff.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: targetUser.id,
triggeredByAdminId: admin.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
const payload: AppleHealthImportPayload = {
userId: targetUser.id,
triggeredByAdminId: admin.id,
uploadPath: uploaded.filePath,
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
await prisma.importJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
await auditLog("admin.import-apple-health.kickoff", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId: bossJobId ?? null,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
return apiSuccess(
{
jobId: importJob.id,
status: "queued" as const,
targetUserId: targetUser.id,
},
202,
);
});
@@ -0,0 +1,77 @@
/**
* `GET /api/import/apple-health-export/[jobId]/status` — polling
* endpoint for an in-flight Apple Health import.
*
* Returns the canonical `ImportJobStatusResponse` envelope (§8 of
* `.planning/research/v1434-r-1-xml-import.md`). The polling cadence
* the iOS client uses:
* - every 2 s while `queued | unpacking | parsing | upserting`
* - every 30 s once `done | failed` (last-known state is fine)
*
* Authorisation: the row's `userId` must match the requester or the
* requester is the admin who kicked it off via the admin variant.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
interface ImportJobStatusResponse {
jobId: string;
status: string;
startedAt: string;
completedAt: string | null;
uploadBytes: number;
exportedAt: string | null;
progress: Record<string, unknown>;
result: Record<string, unknown> | null;
failureReason: string | null;
}
export const GET = apiHandler(
async (_request: NextRequest, ctx: RouteContext) => {
const { user } = await requireAuth();
const { jobId } = await ctx.params;
annotate({
action: { name: "import.apple-health.status" },
meta: { job_id: jobId },
});
const row = await prisma.importJob.findUnique({ where: { id: jobId } });
if (!row) {
return apiError("Import job not found", 404);
}
// Authorisation: the owner can see their own job; the admin who
// triggered it can see it from the admin route. Any other caller
// gets a 404 (not 403 — we don't leak existence to outsiders).
const isOwner = row.userId === user.id;
const isTriggeringAdmin =
row.triggeredByAdminId !== null && row.triggeredByAdminId === user.id;
if (!isOwner && !isTriggeringAdmin) {
return apiError("Import job not found", 404);
}
const body: ImportJobStatusResponse = {
jobId: row.id,
status: row.status,
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt?.toISOString() ?? null,
uploadBytes: row.uploadBytes,
exportedAt: row.exportedAt?.toISOString() ?? null,
progress: (row.progress as Record<string, unknown>) ?? {},
result: (row.result as Record<string, unknown> | null) ?? null,
failureReason: row.failureReason,
};
return apiSuccess(body, 200);
},
);
@@ -0,0 +1,176 @@
/**
* `POST /api/import/apple-health-export` — user-facing kick-off for an
* Apple Health `export.zip` import.
*
* Streams the upload directly to disk (`/tmp/healthlog-upload-*.bin`)
* without buffering the full body in memory, creates an `ImportJob`
* row in `queued`, and enqueues an `apple-health-import` pg-boss job.
* Returns `{ jobId }` so the client can poll
* `GET /api/import/apple-health-export/[jobId]/status`.
*
* Idempotency: a re-upload of the exact same file (same SHA-256 of
* the bytes) short-circuits to the previous `ImportJob` id without
* re-queueing — the iOS client cannot reliably emit a stable
* `Idempotency-Key` for a 1 GB file, so we hash the bytes and dedup
* on content.
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
export const dynamic = "force-dynamic";
/**
* Hard cap on the upload size. Apple's largest observed exports
* sit around 800 MB for a 10-year iCloud-synced account; 1.5 GB
* leaves comfortable margin for the long tail.
*/
const MAX_UPLOAD_BYTES = 1.5 * 1024 * 1024 * 1024;
/** Rate-limit window: three uploads per minute per user. */
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 60_000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.apple-health.kickoff" } });
// Rate limit before touching the body — protects against a flood of
// 1 GB uploads from a single user.
const rl = await checkRateLimit(
`import:apple-health:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
throw new HttpError(429, "Too many import uploads, try again later");
}
// Cheap pre-flight on the declared Content-Length. The streaming
// sink below enforces the hard cap as well — a missing/wrong
// content-length cannot bypass it.
const declaredBytes = Number(request.headers.get("content-length") ?? 0);
if (declaredBytes > MAX_UPLOAD_BYTES) {
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
reason: "content_length_exceeded",
contentLength: declaredBytes,
},
});
return apiError("Upload exceeds 1.5 GB limit", 413);
}
const body = request.body;
if (!body) {
return apiError("Request body is required", 400);
}
// Stream the upload to disk, computing a SHA-256 in the same pass.
let uploaded;
try {
uploaded = await streamMultipartToDisk(
body,
request.headers.get("content-type"),
{
maxBytes: MAX_UPLOAD_BYTES,
fieldName: "file",
tmpPrefix: "healthlog-apple-health-import",
},
);
} catch (err) {
const message = err instanceof Error ? err.message : "upload_failed";
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reason: "stream_to_disk_failed", message },
});
return apiError(`Multipart upload failed: ${message}`, 422);
}
// Content-hash idempotency: a re-upload of the same bytes resolves
// to the previous ImportJob row instead of re-queueing.
const existing = await prisma.importJob.findFirst({
where: {
userId: user.id,
uploadSha256: uploaded.sha256,
},
orderBy: { startedAt: "desc" },
});
if (existing) {
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
jobId: existing.id,
status: existing.status,
idempotent: true,
},
202,
);
}
const boss = getGlobalBoss();
if (!boss) {
await auditLog("import.apple-health.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
const payload: AppleHealthImportPayload = {
userId: user.id,
uploadPath: uploaded.filePath,
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
await prisma.importJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
await auditLog("import.apple-health.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId: bossJobId ?? null,
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
},
});
return apiSuccess(
{
jobId: importJob.id,
status: "queued" as const,
},
202,
);
});