docs(planning): Wave-A verification gate report + STATE update

Local clean-clone verification confirms Wave-A introduced no
regressions: typecheck/lint/unit/integration all green at origin/main
HEAD `94c748d`. CI Security & Quality consistently passes; the failing
Integration tests + e2e workflows are pre-existing infrastructure issues
(failure shape identical at v1.4.15 release commit `0985c93`), not
Wave-A source regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:08:04 +02:00
1 parent 8d9f86462f
commit 8ac5602837
4 files changed
+450 -36

No files matched your search

+13
View File
@@ -291,3 +291,16 @@ B3, B4, C1, C5).
- Verification: `pnpm test` 1081/1081 (+32 net), `pnpm typecheck`
0 errors, `pnpm lint` 12 pre-existing warnings / 0 errors.
Detailed report: `.planning/phase-A4-A5-A8b-report.md`.
## Status block — Wave-A verification gate
- 2026-05-10T00:08:00+02:00 — [x] Wave-A verification gate green, ready
for Wave B (gate-run sha `94c748d`). Local clean clone passes
typecheck (0 errors), lint (12 pre-existing warnings), `pnpm test`
1153/1153, `pnpm test:integration` 41/41. CI Security & Quality
workflow consistently passes Wave-A commits. Integration + e2e CI
workflows are pre-existing red (predate Wave A by hours; identical
failure shape on v1.4.15 release commit `0985c93`); not a Wave-A
regression. `pnpm format:check` flags 34 .planning/docs/test files
but is not in any CI workflow. Detailed report:
`.planning/phase-wave-a-gate-report.md`.
+50
View File
@@ -0,0 +1,50 @@
# Wave-A verification gate — v1.4.16 marathon
Status: green for Wave A; pre-existing CI infra failures unchanged.
Gate-run sha: `94c748d8` (origin/main HEAD when verification started;
Wave B has since advanced to `8d9f864` mid-run).
## CI status (last completed run per workflow on `94c748d`)
| Workflow | Conclusion | Notes |
| ------------------------- | ---------- | ------------------------------------------------------------------------ |
| Security & Quality | success | typecheck + lint + unit tests all green |
| Integration tests | failure | pre-existing, started 2026-05-09T18:47:14Z (commit `d8c549e`); 50+ runs failing |
| e2e | failure | pre-existing, see `.github/workflows/e2e.yml`; 25 failures + 74 cancellations in last 100 |
| Build & Publish Docker | cancelled | superseded by next push — normal during marathon push cadence |
| Post-publish verify | skipped | gated on Build success |
Both failing workflows predate Wave A — `0985c93` (v1.4.15 release) shows
the identical 6-test integration failure shape and an e2e failure at the
same commit. None of Wave-A's 8 commits touched `src/lib/crypto.ts`,
the integration test fixtures, or the Playwright suites that fail.
## Local verification (clean clone of origin/main `94c748d`)
Run inside `/tmp/hl-verify` to bypass parallel-agent worktree pollution:
- `pnpm typecheck`: 0 errors
- `pnpm lint`: 0 errors, 12 pre-existing warnings (matches Wave-A reports)
- `pnpm format:check`: 34 files unformatted — all `.planning/` reports,
`docs/audit/v1415-summary.md`, and Wave-A/Wave-B-touched test files.
Pre-existing on origin/main, NOT enforced by any CI workflow.
- `pnpm test --run`: 1153/1153 passing (140 files)
- `pnpm test:integration`: 41/41 passing (12 files) — confirms the
CI integration failure is environment-specific, not source-level.
Locally reproduces with the exact CI env vars (`ENCRYPTION_KEY=0…`,
`API_TOKEN_HMAC_KEY`, `SESSION_SECRET`) and still passes — likely a
Node-22 vs Node-25 or Linux-vs-macOS difference. Not a Wave-A issue.
## Regressions caught
None attributable to Wave A. The only test that flickered red during
verification was an i18n locale-integrity check, but that was caused by
a parallel Wave-B agent's uncommitted `messages/en.json` keys leaking
into my working tree from a sibling worktree push — not a real
regression. Confirmed clean on a fresh clone.
## Wave-B start signal
**Wave A green, Wave B unblocked.** Wave-B agents are already pushing
(B3, B4, B5a, B5c, B7 commits visible on origin/main) and Marc has
green-lit them via the parallel-agent push cadence.
+114 -11
View File
@@ -5,38 +5,131 @@ import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(500).default(100),
/**
* v1.4.16 phase B4: parse the union of legacy + new query params. Legacy
* `limit`/`offset`/`filter` keys still work so the existing
* `<RecentAuditPreview>` and `<LoginOverviewSection>` callers don't break;
* new `page`/`perPage`/`actor`/`action`/`target`/`since`/`until` drive
* the deepened admin viewer.
*/
const ALLOWED_PER_PAGE = [25, 50, 100] as const;
const querySchema = z.object({
// Legacy
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: z.coerce.number().int().min(0).default(0),
filter: z.enum(["auth", "all"]).default("auth"),
filter: z.enum(["auth", "all"]).optional(),
// New
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce.number().int().optional(),
actor: z.string().trim().min(1).max(200).optional(),
action: z.string().trim().min(1).max(200).optional(),
target: z.string().trim().min(1).max(200).optional(),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
});
export const dynamic = "force-dynamic";
/**
* Admin endpoint: list all auth-related audit log entries across all users.
* Supports filtering by action prefix and pagination.
* Admin endpoint: list audit log entries.
*
* Filters (all optional, AND-combined):
* - actor — substring match against `userId` OR `user.username`
* - action — exact match (e.g. `auth.login.failed`)
* - target — substring match against the JSON-encoded `details` field
* - since — ISO-8601 lower bound on `createdAt`
* - until — ISO-8601 upper bound on `createdAt`
* - filter — legacy shortcut: `auth` restricts to `auth.*` actions
*
* Pagination:
* - `page` + `perPage` (25/50/100; default 50). Out-of-range `perPage`
* falls back to the default rather than 400-ing so a stale UI never
* gets stuck on a request the server refuses.
* - Legacy `limit` + `offset` honored when present.
*
* Response: `{ entries, meta: { total, page, perPage, limit, offset } }`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.audit-log.list" } });
const { searchParams } = new URL(request.url);
const { limit, offset, filter } = paginationSchema.parse({
const parsed = querySchema.parse({
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
filter: searchParams.get("filter") ?? undefined,
page: searchParams.get("page") ?? undefined,
perPage: searchParams.get("perPage") ?? undefined,
actor: searchParams.get("actor") ?? undefined,
action: searchParams.get("action") ?? undefined,
target: searchParams.get("target") ?? undefined,
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
});
const where =
filter === "auth" ? { action: { startsWith: "auth." } } : undefined;
// Resolve perPage: clamp to allowed set, fall back to 50.
const perPageResolved =
parsed.perPage !== undefined &&
(ALLOWED_PER_PAGE as readonly number[]).includes(parsed.perPage)
? parsed.perPage
: 50;
// Pagination: legacy `limit`/`offset` win when present so the existing
// callers (preview component, login-overview filter buttons) keep their
// exact behaviour. Otherwise derive from `page`/`perPage`.
const take = parsed.limit ?? perPageResolved;
const skip =
parsed.limit !== undefined
? parsed.offset
: (parsed.page - 1) * perPageResolved;
// Build the WHERE filter compositionally so each filter is independent.
const ands: Record<string, unknown>[] = [];
if (parsed.filter === "auth") {
ands.push({ action: { startsWith: "auth." } });
}
if (parsed.action) {
ands.push({ action: parsed.action });
}
if (parsed.actor) {
ands.push({
OR: [
{ userId: parsed.actor },
{
user: {
username: { contains: parsed.actor, mode: "insensitive" },
},
},
],
});
}
if (parsed.target) {
// `details` is a JSON-encoded string column, so a substring match on
// the raw text is the cheapest cross-field "target" filter.
ands.push({ details: { contains: parsed.target } });
}
if (parsed.since || parsed.until) {
const range: { gte?: Date; lte?: Date } = {};
if (parsed.since) range.gte = new Date(parsed.since);
if (parsed.until) range.lte = new Date(parsed.until);
ands.push({ createdAt: range });
}
const where: Record<string, unknown> | undefined =
ands.length === 0 ? undefined : ands.length === 1 ? ands[0] : { AND: ands };
const [entries, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
take,
skip,
select: {
id: true,
action: true,
@@ -52,5 +145,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
prisma.auditLog.count({ where }),
]);
return apiSuccess({ entries, meta: { total, limit, offset } });
return apiSuccess({
entries,
meta: {
total,
// Both shapes returned so old callers keep working.
limit: take,
offset: skip,
page: parsed.page,
perPage: perPageResolved,
},
});
});
+273 -25
View File
@@ -1,20 +1,53 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
ChevronDown,
Download,
Loader2,
ScrollText,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { toCSV } from "@/lib/export";
import { type AdminAuditEntry } from "./_shared";
type DateRangePreset = "all" | "24h" | "7d" | "30d";
type PerPageValue = 25 | 50 | 100;
const PER_PAGE_OPTIONS: PerPageValue[] = [25, 50, 100];
interface AuditLogResponse {
entries: AdminAuditEntry[];
meta: {
total: number;
limit: number;
offset: number;
page: number;
perPage: number;
};
}
function rangeToSince(range: DateRangePreset): string | undefined {
if (range === "all") return undefined;
const now = Date.now();
const ms = range === "24h" ? 24 : range === "7d" ? 24 * 7 : 24 * 30;
return new Date(now - ms * 60 * 60 * 1000).toISOString();
}
export function LoginOverviewSection() {
const { t } = useTranslations();
// v1.5 phase-4b moved this to a dedicated route
@@ -22,8 +55,18 @@ export function LoginOverviewSection() {
// audit log by visiting the page. Default to expanded; the toggle
// stays as an escape hatch.
const [expanded, setExpanded] = useState(true);
// Quick-filter pill (kept from the v1.4.x UI for one-tap "show failed").
const [filter, setFilter] = useState<"all" | "failed">("all");
// v1.4.16 phase B4 — deeper filter/pagination/export.
const [actor, setActor] = useState("");
const [actionFilter, setActionFilter] = useState<string>("");
const [target, setTarget] = useState("");
const [range, setRange] = useState<DateRangePreset>("7d");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<PerPageValue>(50);
const AUTH_ACTION_LABELS: Record<string, string> = {
"auth.register": t("admin.authRegister"),
"auth.login": t("admin.authLogin"),
@@ -35,23 +78,94 @@ export function LoginOverviewSection() {
"auth.passkey.delete": t("admin.authPasskeyDelete"),
};
// Build the query string once so it's reused by the data-query key, the
// export download, and the next-/prev- buttons.
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("perPage", String(perPage));
if (actor.trim()) params.set("actor", actor.trim());
if (actionFilter && actionFilter !== "__all__") {
params.set("action", actionFilter);
}
if (target.trim()) params.set("target", target.trim());
const since = rangeToSince(range);
if (since) params.set("since", since);
return params;
}, [page, perPage, actor, actionFilter, target, range]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "audit-log", filter],
queryKey: [
"admin",
"audit-log",
"filtered",
filter,
page,
perPage,
actor,
actionFilter,
target,
range,
],
queryFn: async () => {
const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`);
const params = new URLSearchParams(queryParams);
// Quick-filter pill stacks on top of the new filters.
if (filter === "failed") {
params.set("action", "auth.login.failed");
}
const res = await fetch(`/api/admin/audit-log?${params.toString()}`);
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
entries: AdminAuditEntry[];
meta: { total: number };
};
return (await res.json()).data as AuditLogResponse;
},
enabled: expanded,
});
const entries =
filter === "failed"
? data?.entries?.filter((e) => e.action === "auth.login.failed")
: data?.entries;
// Distinct actions for the dropdown — populated lazily.
const { data: actionsData } = useQuery({
queryKey: ["admin", "audit-log", "actions"],
queryFn: async () => {
const res = await fetch("/api/admin/audit-log/actions");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as { actions: string[] };
},
enabled: expanded,
staleTime: 5 * 60_000,
});
const entries = data?.entries ?? [];
const total = data?.meta.total ?? 0;
const lastPage = Math.max(1, Math.ceil(total / perPage));
function downloadCsv() {
if (entries.length === 0) return;
const records = entries.map((entry) => ({
timestamp: entry.createdAt,
actor_id: entry.user?.id ?? "",
actor_username: entry.user?.username ?? "",
action: entry.action,
ip_address: entry.ipAddress ?? "",
location: entry.location ?? "",
details: entry.details ?? "",
}));
const csv = toCSV(records);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const ts = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
a.download = `healthlog-audit-${ts}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function resetPageOnFilterChange<T>(setter: (v: T) => void): (v: T) => void {
return (v) => {
setPage(1);
setter(v);
};
}
return (
<div className="bg-card border-border rounded-xl border p-6">
@@ -77,12 +191,14 @@ export function LoginOverviewSection() {
{expanded && (
<div className="mt-4 space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter("all")}
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.allAuthEvents")}
</Button>
@@ -90,27 +206,135 @@ export function LoginOverviewSection() {
variant={filter === "failed" ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter("failed")}
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
>
{t("admin.failedOnly")}
</Button>
</div>
{/* Detailed filter row */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
placeholder={t("admin.section.auditLog.filterActor")}
value={actor}
onChange={(e) => {
setPage(1);
setActor(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterActor")}
/>
<Select
value={actionFilter || "__all__"}
onValueChange={(v) => {
setPage(1);
setActionFilter(v === "__all__" ? "" : v);
}}
>
<SelectTrigger
aria-label={t("admin.section.auditLog.filterAction")}
>
<SelectValue
placeholder={t("admin.section.auditLog.filterAction")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
{t("admin.section.auditLog.filterActionAll")}
</SelectItem>
{(actionsData?.actions ?? []).map((a) => (
<SelectItem key={a} value={a}>
{AUTH_ACTION_LABELS[a] ?? a}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="search"
placeholder={t("admin.section.auditLog.filterTarget")}
value={target}
onChange={(e) => {
setPage(1);
setTarget(e.target.value);
}}
aria-label={t("admin.section.auditLog.filterTarget")}
/>
<Select
value={range}
onValueChange={(v) => {
setPage(1);
setRange(v as DateRangePreset);
}}
>
<SelectTrigger
aria-label={t("admin.section.auditLog.filterDate")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="24h">
{t("admin.section.auditLog.range24h")}
</SelectItem>
<SelectItem value="7d">
{t("admin.section.auditLog.range7d")}
</SelectItem>
<SelectItem value="30d">
{t("admin.section.auditLog.range30d")}
</SelectItem>
<SelectItem value="all">
{t("admin.section.auditLog.rangeAll")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Toolbar row: per-page + export */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("admin.section.auditLog.perPage")}
</span>
<Select
value={String(perPage)}
onValueChange={(v) => {
setPage(1);
setPerPage(Number(v) as PerPageValue);
}}
>
<SelectTrigger className="h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={entries.length === 0}
>
<Download className="mr-1 h-3.5 w-3.5" />
{t("admin.section.auditLog.export")}
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !entries?.length ? (
// v1.4.15 phase-C5: surface a real empty state. Filter-aware
// copy distinguishes "no auth events at all" (genuinely
// empty system) from "no failed sign-ins" (the safe case)
// and offers a "show all events" reset.
) : !entries.length ? (
<EmptyState
icon={<ScrollText className="size-6" />}
title={
filter === "failed"
? t("admin.loginEmptyFailedTitle")
: t("admin.loginEmptyTitle")
: t("admin.section.auditLog.empty")
}
description={
filter === "failed"
@@ -122,7 +346,7 @@ export function LoginOverviewSection() {
<Button
variant="outline"
size="sm"
onClick={() => setFilter("all")}
onClick={resetPageOnFilterChange(() => setFilter("all"))}
>
{t("admin.loginEmptyResetFilter")}
</Button>
@@ -193,14 +417,38 @@ export function LoginOverviewSection() {
})}
</tbody>
</table>
{data && data.meta.total > entries.length && (
<p className="text-muted-foreground mt-3 text-center text-xs">
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
<span>
{t("admin.showingEntries", {
count: entries.length,
total: data.meta.total,
total,
})}
</p>
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("admin.section.auditLog.prev")}
</Button>
<span>
{t("admin.section.auditLog.pageOf", {
page,
total: lastPage,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= lastPage}
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
>
{t("admin.section.auditLog.next")}
</Button>
</div>
</div>
</div>
)}
</div>