feat(admin): fill the redacted support report with its redaction guard

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:15:50 +02:00
1 parent 1af0de5e87
commit b873ce8bc8
8 files changed
+1801 -5

No files matched your search

@@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"admin.pulse_day_mean.get",
"admin.pulse_day_mean.post",
],
"admin/support-report/route.ts": ["admin.support_report.get"],
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
"anamnesis/life-circumstances/ai-sections/route.ts": [
@@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 24;
const REGISTER_SIZE = 23;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
@@ -0,0 +1,277 @@
/**
* `GET /api/admin/support-report` end to end over a mocked database: the
* collector, the renderer and the redaction together. Accounts are seeded
* with distinctive emails, ids, usernames and names, and the failures carry
* a token, a URL and a host in their messages; none of it may reach the text.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const USERS = [
{
id: "cmownerabcdefghijklmnopq",
username: "ownerperson",
email: "owner.person@example.org",
fullName: "Olive Owner",
aiUsageLevel: null,
},
{
id: "cmsecondabcdefghijklmnop",
username: "secondperson",
email: "second.person@mailbox.org",
fullName: "Sam Second",
aiUsageLevel: "FULL",
},
];
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findMany: vi.fn(async () => USERS),
groupBy: vi.fn(async () => [
{ aiProvider: "LOCAL", _count: { _all: 1 } },
{ aiProvider: null, _count: { _all: 1 } },
]),
},
appSettings: {
findUnique: vi.fn(async () => ({
glitchtipEnabled: true,
glitchtipDsn: "https://key@glitchtip.example.net/1",
webPushVapidPublicKey: null,
webPushVapidPrivateKeyEncrypted: null,
adminAiKeyEncrypted: "v1:ciphertext",
adminCodexConnectionStatus: "disconnected",
aiUsageDefaultLevel: "STANDARD",
aiUsageMaxLevel: "FULL",
})),
},
$queryRaw: vi.fn(async (strings: TemplateStringsArray) => {
const sql = strings.join("?");
if (sql.includes("server_version")) {
return [
{ version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) },
];
}
if (sql.includes("migration_name")) {
return [
{
migration_name: "0410_relation_links",
finished_at: new Date("2026-10-15T08:00:00Z"),
},
];
}
if (sql.includes("finished_at IS NULL")) return [{ count: 0 }];
if (sql.includes("pgboss.job")) {
return [{ at: new Date("2026-10-15T09:59:00Z") }];
}
return [];
}),
integrationWatch: {
findMany: vi.fn(async () => [
{
userId: "cmsecondabcdefghijklmnop",
integration: "nightscout",
incidentReason: "unexpected_silence",
incidentOpenedAt: new Date("2026-10-14T00:00:00Z"),
alertedAt: null,
},
]),
},
providerHealth: {
findMany: vi.fn(async () => [
{
providerType: "LOCAL",
lastResult: "hard_failed",
lastStatus: 502,
lastFailureAt: new Date("2026-10-15T07:00:00Z"),
nextRetryAt: new Date(Date.now() + 60_000),
},
]),
},
pushAttempt: {
groupBy: vi.fn(async (args: { by: string[] }) =>
args.by.includes("result")
? [
{ channel: "NTFY", result: "ok", _count: { _all: 9 } },
{ channel: "NTFY", result: "error", _count: { _all: 2 } },
]
: [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }],
),
},
dataBackup: {
aggregate: vi.fn(async () => ({
_max: { createdAt: new Date("2026-10-12T03:00:00Z") },
})),
count: vi.fn(async () => 2),
},
offhostBackupState: {
findMany: vi.fn(async () => [
{ lastSuccessAt: new Date("2026-10-15T02:00:00Z") },
{ lastSuccessAt: null },
]),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/jobs/job-failures", () => ({
JOB_FAILURE_WINDOW_HOURS: 72,
readFailingQueues: vi.fn(async () => [
{
queue: "withings-sync",
failures: 4,
lastFailedAt: "2026-10-15T09:00:00.000Z",
lastError:
"TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org",
},
]),
readLastQueueRun: vi.fn(async (queue: string) =>
queue === "data-restore-drill"
? {
at: "2026-10-01T04:11:00.000Z",
state: "failed",
error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)",
}
: null,
),
}));
vi.mock("@/lib/integrations/connection-health-load", () => ({
loadConnectionHealth: vi.fn(async (userId: string) => {
const empty = {
withings: null,
whoop: null,
fitbit: null,
"google-health": null,
polar: null,
oura: null,
strava: null,
nightscout: null,
"apple-health": null,
};
if (userId === "cmownerabcdefghijklmnopq") {
return {
...empty,
withings: { state: "ok", reason: "data_arriving" },
};
}
return {
...empty,
withings: { state: "action_required", reason: "reauth_required" },
nightscout: { state: "degraded", reason: "unexpected_silence" },
};
}),
}));
vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false }));
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
import { GET } from "../route";
const FORBIDDEN = [
...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]),
"wbsapi.withings.net",
"access_token",
"glitchtip.example.net",
"10.0.0.12",
"s3.nas.lan",
"5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
"hlk_leaked_token",
];
beforeEach(() => {
vi.mocked(requireAdmin).mockReset();
clearLogBuffer();
});
async function report(): Promise<{ generatedAt: string; text: string }> {
const res = await GET();
expect(res.status).toBe(200);
return (await res.json()).data;
}
describe("GET /api/admin/support-report", () => {
it("refuses a non-admin before reading anything", async () => {
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
await expect(GET()).rejects.toThrow("Forbidden");
});
it("answers the redacted report with every section", async () => {
appendLogEvent({
timestamp: "2026-10-15T09:40:00.000Z",
duration_ms: 20,
request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
trace_id: "t",
level: "error",
kind: "http",
service: "healthlog",
environment: "test",
action: { name: "measurement.batch.ingest" },
auth: { user_id: "cmownerabcdefghijklmnopq" },
error: {
type: "TypeError",
message: "Bearer hlk_leaked_token for owner.person@example.org",
},
});
const { generatedAt, text } = await report();
expect(Date.parse(generatedAt)).not.toBeNaN();
for (const heading of [
"## Build",
"## Database",
"## Configuration",
"## Jobs",
"## Connections",
"## AI",
"## Notifications (7 days)",
"## Backups",
"## Recent warnings and errors (this process)",
]) {
expect(text).toContain(heading);
}
expect(text).toContain("- PostgreSQL: 16.4");
expect(text).toContain("0410_relation_links");
expect(text).toContain("- GlitchTip: yes");
expect(text).toContain("withings-sync: 4 failed");
expect(text).toContain("(TypeError)");
expect(text).toContain(
"- withings: action_required / reauth_required: 1 (Account 2)",
);
expect(text).toContain("- withings: ok / data_arriving: 1");
expect(text).toContain("nightscout: unexpected_silence since");
expect(text).toContain("Account 2");
expect(text).toContain(
"- Accounts by usage level: FULL 1, instance default 1",
);
expect(text).toContain(
"LOCAL: ok 0, failing 1, credential refused 0, paused 1",
);
expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2");
expect(text).toContain(
"Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)",
);
expect(text).toContain("- error measurement.batch.ingest: 1x");
for (const value of FORBIDDEN) {
expect(text.toLowerCase(), value).not.toContain(value.toLowerCase());
}
});
});
+21 -2
View File
@@ -5,13 +5,32 @@
* backups and the most frequent recent error actions. Never an email address,
* a user id, a value, a token or a foreign host name; accounts appear as
* "Account 1..n". Admin only, cookie session only.
*
* What goes in and how it is redacted lives in `src/lib/support/report.ts`;
* the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
buildSupportReportText,
collectSupportReportFacts,
} from "@/lib/support/report";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
return notImplemented("admin.support_report.get");
annotate({ action: { name: "admin.support_report.get" } });
const { facts, context } = await collectSupportReportFacts();
const text = buildSupportReportText(facts, context);
annotate({
meta: {
support_report_accounts: facts.accounts.total,
support_report_bytes: text.length,
},
});
return apiSuccess({ generatedAt: facts.generatedAt, text });
});
@@ -29,7 +29,8 @@ function call(
handler: () => Promise<Response>,
headers: Record<string, string> = {},
) {
return apiHandler(handler)(
const route = apiHandler(async (_request: NextRequest) => handler());
return route(
new NextRequest("http://localhost/api/probe", { headers }),
);
}
@@ -0,0 +1,309 @@
/**
* Redaction guard for the operator's support report.
*
* The report is pasted into public issues. This seeds every free-text slot it
* carries with something it must never print (an account's email, id and
* username, a token, a URL, a foreign host, an address, a request id) and
* proves two things:
*
* 1. the finished text contains none of them;
* 2. every redaction step is load-bearing: with any single step dropped,
* the seed of its class reaches the output. A step that can be removed
* without a test going red would be a step nobody notices is gone.
*/
import { describe, expect, it } from "vitest";
import {
buildSupportReportText,
errorClassOf,
renderSupportReport,
summariseProblemEvents,
type SupportReportFacts,
} from "../report";
import {
redactSupportText,
SUPPORT_REDACTION_STEPS,
type SupportRedactionContext,
} from "../redact";
import type { WideEvent } from "@/lib/logging/types";
const ACCOUNT_EMAIL = "anna.schmidt@example.org";
const ACCOUNT_ID = "cmq7x0abcdefghijklmnopqrs";
const ACCOUNT_USERNAME = "annaschmidt";
const ACCOUNT_NAME = "Anna Schmidt";
/** One seed per step; each is caught by its own step and by no other. */
const SEEDS = {
"known-values": ACCOUNT_USERNAME,
secrets: "sk-ant-api03-Zq9Xw8Vu7Ts6",
urls: "smb://nas/backups",
emails: "someone@",
ids: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
"ip-addresses": "192.168.17.42",
"host-names": "gotify.home.arpa",
} as const;
const CONTEXT: SupportRedactionContext = {
knownValues: [ACCOUNT_ID, ACCOUNT_USERNAME, ACCOUNT_EMAIL, ACCOUNT_NAME],
};
function seededFacts(): SupportReportFacts {
return {
generatedAt: "2026-10-15T10:00:00.000Z",
build: {
version: "1.43.0",
buildSha: "abc1234",
builtAt: "2026-10-15T08:00:00.000Z",
node: "v22.11.0",
processType: "web,worker",
heapLimitMb: 792,
uptimeSeconds: 7260,
},
database: {
version: "16.4",
timezone: "Etc/UTC",
sizeBytes: 104857600,
lastMigration: "0410_relation_links",
lastMigrationAt: "2026-10-15T08:05:00.000Z",
unfinishedMigrations: 0,
},
config: [
["Secure cookie flag", "yes"],
// A value that should only ever be yes/no, carrying a URL anyway.
["AI provider types chosen by accounts", `OPENAI 1, ${SEEDS.urls}`],
],
jobs: {
workerInThisProcess: true,
workerRunning: true,
workerLastHeartbeat: "2026-10-15T09:59:00.000Z",
newestFinishedJobAt: "2026-10-15T09:59:30.000Z",
failing: [
{
queue: "environment-fetch",
failures: 3,
lastFailedAt: "2026-10-15T09:00:00.000Z",
errorClass: `Error for ${ACCOUNT_EMAIL}`,
},
],
},
accounts: { total: 2, read: 2 },
connections: [
{
provider: "withings",
state: "action_required",
reason: "reauth_required",
accounts: [2],
},
{
provider: "oura",
state: "degraded",
reason: `provider_errors ${SEEDS["ip-addresses"]}`,
accounts: [1],
},
],
incidents: [
{
provider: "nightscout",
reason: `unexpected_silence ${SEEDS["host-names"]}`,
openedAt: "2026-10-14T00:00:00.000Z",
announced: true,
account: 1,
},
],
ai: {
defaultLevel: "STANDARD",
maxLevel: "FULL",
levels: [["instance default", 2]],
providers: [
{
provider: "LOCAL",
ok: 1,
failing: 1,
authFailed: 0,
paused: 0,
lastFailureAt: "2026-10-15T07:00:00.000Z",
lastStatus: 502,
},
],
},
notifications: [
{
channel: "NTFY",
ok: 10,
error: 2,
skipped: 0,
topReasons: [[`http_500 ${SEEDS.ids}`, 2]],
},
],
backups: {
newestLocalAt: "2026-10-12T03:00:00.000Z",
localCopies: 2,
lastLocalRun: {
at: "2026-10-12T03:10:00.000Z",
state: "completed",
errorClass: null,
},
offhostConfigured: true,
offhostNewestAt: "2026-10-15T02:00:00.000Z",
offhostOldestAt: "2026-10-15T01:00:00.000Z",
offhostAccounts: 2,
offhostNeverStored: 0,
lastOffhostRun: null,
lastRestoreDrill: {
at: "2026-10-01T04:11:00.000Z",
state: "failed",
errorClass: `TypeError Bearer ${SEEDS.secrets}`,
},
},
errors: {
ringSize: 3,
ringMax: 1000,
top: [
{
action: `integration.sync.${ACCOUNT_ID}`,
level: "error",
count: 2,
lastSeen: "2026-10-15T09:30:00.000Z",
errorClass: `${ACCOUNT_USERNAME} Error`,
},
{
action: "notification.send",
level: "warn",
count: 1,
lastSeen: "2026-10-15T09:20:00.000Z",
errorClass: `someone@mailbox.org ${ACCOUNT_NAME}`,
},
],
},
};
}
const FORBIDDEN = [
ACCOUNT_EMAIL,
ACCOUNT_ID,
ACCOUNT_USERNAME,
ACCOUNT_NAME,
...Object.values(SEEDS),
];
describe("support report redaction", () => {
it("prints none of the seeded values", () => {
const text = buildSupportReportText(seededFacts(), CONTEXT);
for (const value of FORBIDDEN) {
expect(text.toLowerCase(), value).not.toContain(value.toLowerCase());
}
// The report itself survived: sections, accounts as numbers, dates.
expect(text).toContain("# HealthLog support report");
expect(text).toContain("Account 2");
expect(text).toContain("2026-10-15T09:30:00.000Z");
expect(text).toContain("0410_relation_links");
expect(text).toContain("environment-fetch");
});
it("the seeds really are in the unredacted text", () => {
const raw = renderSupportReport(seededFacts());
for (const value of FORBIDDEN) expect(raw).toContain(value);
});
it.each(SUPPORT_REDACTION_STEPS.map((s) => s.name))(
"goes red without the %s step",
(name) => {
const seed = SEEDS[name as keyof typeof SEEDS];
expect(seed, `no seed for step ${name}`).toBeDefined();
const without = SUPPORT_REDACTION_STEPS.filter((s) => s.name !== name);
const text = redactSupportText(
renderSupportReport(seededFacts()),
CONTEXT,
without,
);
expect(text.toLowerCase()).toContain(seed.toLowerCase());
},
);
it("has a seed for every step, so a new step needs one", () => {
expect(Object.keys(SEEDS).sort()).toEqual(
SUPPORT_REDACTION_STEPS.map((s) => s.name).sort(),
);
});
it("leaves dates, versions and clock times alone", () => {
const text = redactSupportText(
"- Node: v22.11.0\n- Built at: 2026-10-15T08:00:00.000Z\n- Version: 1.43.0",
CONTEXT,
);
expect(text).toContain("v22.11.0");
expect(text).toContain("2026-10-15T08:00:00.000Z");
expect(text).toContain("1.43.0");
});
});
describe("error classes never carry the message", () => {
it("names the class or code and drops the rest", () => {
expect(
errorClassOf(
`TypeError: fetch failed for https://gotify.home.arpa/?token=abc (${ACCOUNT_EMAIL})`,
),
).toBe("TypeError");
expect(errorClassOf("connect ECONNREFUSED 192.168.17.42:5432")).toBe(
"ECONNREFUSED",
);
expect(
errorClassOf("Invalid `prisma.user.update()` invocation: P2002 unique"),
).toBe("P2002");
expect(errorClassOf("Withings answered HTTP 503 for annaschmidt")).toBe(
"503",
);
expect(errorClassOf("job timed out: did not finish within 1 h")).toBe(
"timeout",
);
expect(errorClassOf("anything at all, an email a@b.co")).toBe("Error");
expect(errorClassOf(null)).toBe("unknown");
});
it("groups problem events by action without reading their messages", () => {
const event = (over: Partial<WideEvent>): WideEvent => ({
timestamp: "2026-10-15T09:00:00.000Z",
duration_ms: 5,
request_id: SEEDS.ids,
trace_id: "t",
level: "error",
kind: "http",
service: "healthlog",
environment: "test",
...over,
});
const top = summariseProblemEvents([
event({
action: { name: "measurement.batch.ingest" },
error: {
type: "PrismaClientKnownRequestError",
code: "P2002",
message: `duplicate for ${ACCOUNT_EMAIL}`,
},
}),
event({
timestamp: "2026-10-15T09:05:00.000Z",
action: { name: "measurement.batch.ingest" },
error: { type: "Error", message: "Bearer hlk_secret_value_here" },
}),
event({
level: "warn",
http: { method: "GET", path: "/x", route: "/x", status: 404 },
}),
]);
expect(top[0]).toEqual({
action: "measurement.batch.ingest",
level: "error",
count: 2,
lastSeen: "2026-10-15T09:05:00.000Z",
errorClass: "Error",
});
expect(top[1]).toMatchObject({
action: "http GET 404",
errorClass: "HTTP 404",
});
expect(JSON.stringify(top)).not.toContain(ACCOUNT_EMAIL);
expect(JSON.stringify(top)).not.toContain("hlk_secret");
expect(JSON.stringify(top)).not.toContain(SEEDS.ids);
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Redaction for the support report.
*
* The report is meant to be pasted into a public issue. It is built from
* counts, states and closed codes, so in the ordinary case there is nothing
* here to remove. These steps are the second layer: the free-text fields the
* report does carry (an error class, a delivery reason, an action name) come
* from places a future change could fill with something it should not, and
* the whole rendered text runs through every step before it leaves.
*
* The steps are an ordered list rather than one function so the guard test
* can drop each one in turn and prove it is load-bearing: with any single
* step removed, a seeded value of its class reaches the output.
*
* Never an email address, an account id, a token, a host name, an address or
* a value an account wrote.
*/
import { redactSecrets } from "@/lib/logging/redact";
/** Values that belong to this instance's accounts and must never appear. */
export interface SupportRedactionContext {
/** Email addresses, usernames, display names, account ids. */
knownValues: readonly string[];
}
export interface SupportRedactionStep {
name: string;
apply: (text: string, context: SupportRedactionContext) => string;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Shortest known value replaced. Shorter usernames (`ann`, `max`) are left to
* the structural rules: the report prints no username anywhere, and a
* three-letter match would rewrite ordinary words of the report itself.
*/
const KNOWN_VALUE_MIN_LENGTH = 5;
/** Top-level domains a host name in a self-hosted setup ends in. */
const HOST_TLDS = [
"com",
"net",
"org",
"io",
"dev",
"app",
"cloud",
"info",
"me",
"de",
"at",
"ch",
"eu",
"uk",
"fr",
"es",
"it",
"pl",
"nl",
"kr",
"us",
"local",
"lan",
"home",
"internal",
"arpa",
"localdomain",
"ts",
].join("|");
export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [
{
// Exact values of this instance's accounts, wherever they turn up.
name: "known-values",
apply: (text, context) => {
let out = text;
const values = [...new Set(context.knownValues)]
.map((v) => v.trim())
.filter((v) => v.length >= KNOWN_VALUE_MIN_LENGTH)
// Longest first, so an email is replaced before the username in it.
.sort((a, b) => b.length - a.length);
for (const value of values) {
out = out.replace(new RegExp(escapeRegExp(value), "gi"), "[account]");
}
return out;
},
},
{
// Bearer tokens, API keys, path and query secrets: the shared scrubber.
name: "secrets",
apply: (text) => redactSecrets(text),
},
{
name: "urls",
apply: (text) =>
text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s)>\]]+/gi, "[url]"),
},
{
name: "emails",
apply: (text) =>
text.replace(
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
"[email]",
),
},
{
// UUIDs and cuid-shaped ids: account, record and request ids.
name: "ids",
apply: (text) =>
text
.replace(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
"[id]",
)
.replace(/\bc[a-z0-9]{20,32}\b/g, "[id]"),
},
{
name: "ip-addresses",
apply: (text) =>
text
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
// Four groups or more, so a clock time (`10:00:00`) is not one.
.replace(/\b(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}\b/gi, "[ip]")
.replace(/(?:\b[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:?)+/gi, "[ip]"),
},
{
name: "host-names",
apply: (text) =>
text.replace(
new RegExp(
`\\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+(?:${HOST_TLDS})\\b`,
"gi",
),
"[host]",
),
},
];
/** Run every step (or the given list) over the text, in order. */
export function redactSupportText(
text: string,
context: SupportRedactionContext,
steps: readonly SupportRedactionStep[] = SUPPORT_REDACTION_STEPS,
): string {
return steps.reduce((acc, step) => step.apply(acc, context), text);
}
/**
* One line of free text, made safe to stand in the report: control
* characters out, whitespace folded, cut to `max`. The content itself is
* left to the redaction steps, which see the whole report.
*/
export function supportLine(value: unknown, max = 80): string {
if (typeof value !== "string" && typeof value !== "number") return "";
const folded = String(value)
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return folded.length > max ? `${folded.slice(0, max - 1)}…` : folded;
}
+875
View File
@@ -0,0 +1,875 @@
/**
* The operator's support report: a redacted plain-text summary of what this
* instance looks like, for pasting into a bug report.
*
* Three stages, kept apart so each can be tested on its own:
*
* 1. `collectSupportReportFacts()` reads the database, the queue and this
* process. Accounts leave it as numbers ("Account 1..n", oldest first);
* no account id, username or email is part of the facts.
* 2. `renderSupportReport()` turns the facts into text. Pure.
* 3. `redactSupportText()` runs every redaction step over that text, with
* the instance's own emails, usernames, names and ids as known values.
*
* What it never contains: an email address, an account id, a value anyone
* recorded, a token, the host name of a foreign service. Configuration is yes
* or no, never the setting itself. Errors appear as an action name and an
* error class, never as their message.
*
* The report is English on purpose: it is read by whoever answers the issue.
*/
import { getHeapStatistics } from "node:v8";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
import { getLoggingConfig } from "@/lib/logging/config";
import {
readLogBuffer,
PROBLEMS_BUFFER_MAX,
} from "@/lib/logging/in-memory-buffer";
import type { WideEvent } from "@/lib/logging/types";
import {
offhostBackupConfigured,
OFFHOST_BACKUP_QUEUE,
} from "@/lib/jobs/offhost-backup";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill";
import {
JOB_FAILURE_WINDOW_HOURS,
readFailingQueues,
readLastQueueRun,
type LastQueueRun,
} from "@/lib/jobs/job-failures";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { getProcessType, shouldRunWorker } from "@/lib/process-type";
import { offlineGeoReady } from "@/lib/geo";
import { configuredNotificationPrivateOrigins } from "@/lib/notifications/egress-policy";
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { CONNECTION_KEYS } from "@/lib/integrations/connection-health";
import {
redactSupportText,
supportLine,
type SupportRedactionContext,
} from "./redact";
import packageJson from "../../../package.json";
/** Accounts whose connections the report reads; the rest are counted only. */
export const SUPPORT_REPORT_ACCOUNT_CAP = 200;
/** How far back the notification tallies reach. */
export const SUPPORT_REPORT_NOTIFICATION_DAYS = 7;
/** How many error actions the report lists. */
export const SUPPORT_REPORT_TOP_ERRORS = 20;
// ─── Facts ───────────────────────────────────────────────────────────────
export interface QueueRunFact {
at: string;
state: LastQueueRun["state"];
errorClass: string | null;
}
export interface SupportReportFacts {
generatedAt: string;
build: {
version: string;
buildSha: string | null;
builtAt: string | null;
node: string;
processType: string;
heapLimitMb: number;
uptimeSeconds: number;
};
database: {
version: string | null;
timezone: string | null;
sizeBytes: number | null;
lastMigration: string | null;
lastMigrationAt: string | null;
unfinishedMigrations: number | null;
};
/** Label and a yes / no / count answer. Never the setting itself. */
config: Array<[string, string]>;
jobs: {
workerInThisProcess: boolean;
workerRunning: boolean;
workerLastHeartbeat: string | null;
newestFinishedJobAt: string | null;
/** `null` when the queue tables could not be read. */
failing: Array<{
queue: string;
failures: number;
lastFailedAt: string;
errorClass: string;
}> | null;
};
accounts: { total: number; read: number };
connections: Array<{
provider: string;
state: string;
reason: string;
accounts: number[];
}>;
incidents: Array<{
provider: string;
reason: string;
openedAt: string | null;
announced: boolean;
account: number | null;
}>;
ai: {
defaultLevel: string | null;
maxLevel: string | null;
/** Level (or `instance default`) to number of accounts. */
levels: Array<[string, number]>;
providers: Array<{
provider: string;
ok: number;
failing: number;
authFailed: number;
paused: number;
lastFailureAt: string | null;
lastStatus: number | null;
}>;
};
notifications: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
topReasons: Array<[string, number]>;
}>;
backups: {
newestLocalAt: string | null;
localCopies: number;
lastLocalRun: QueueRunFact | null;
offhostConfigured: boolean;
offhostNewestAt: string | null;
offhostOldestAt: string | null;
offhostAccounts: number;
offhostNeverStored: number;
lastOffhostRun: QueueRunFact | null;
lastRestoreDrill: QueueRunFact | null;
};
errors: {
ringSize: number;
ringMax: number;
top: Array<{
action: string;
level: string;
count: number;
lastSeen: string;
errorClass: string;
}>;
};
}
// ─── Error classes ───────────────────────────────────────────────────────
/**
* A short class for a failure text: never the message. Timeouts and lost
* heartbeats by name, otherwise the thrown class (`TypeError`), a Prisma code
* (`P2002`), a system code (`ECONNREFUSED`) or an HTTP status, whichever the
* text names; plain `Error` when it names none.
*/
export function errorClassOf(text: string | null | undefined): string {
if (!text) return "unknown";
const trimmed = text.trim();
if (/^(job timed out|handler execution exceeded)/i.test(trimmed)) {
return "timeout";
}
if (/^job heartbeat timeout/i.test(trimmed)) return "heartbeat_timeout";
const parts: string[] = [];
const named = /^([A-Z][A-Za-z0-9]{1,60}(?:Error|Exception))\b/.exec(trimmed);
if (named) parts.push(named[1]);
const code =
/\b(P\d{4})\b/.exec(trimmed) ??
/\b(E[A-Z][A-Z_]{2,30})\b/.exec(trimmed) ??
/\b(?:HTTP|status)\s*:?\s*([1-5]\d{2})\b/i.exec(trimmed);
if (code) parts.push(code[1]);
return parts.length > 0 ? parts.join(" ") : "Error";
}
/** The class of a wide event's failure: thrown class and code, else its level. */
export function eventErrorClass(event: WideEvent): string {
if (event.error) {
const type = supportLine(event.error.type, 60) || "Error";
const code =
event.error.code !== undefined && event.error.code !== null
? supportLine(String(event.error.code), 40)
: "";
return code ? `${type} ${code}` : type;
}
const status = event.http?.status;
return typeof status === "number" && status >= 400 ? `HTTP ${status}` : "-";
}
/** The name an event is grouped under: its action, else its kind and status. */
function eventActionName(event: WideEvent): string {
const name = supportLine(event.action?.name, 80);
if (name) return name;
if (event.kind === "background") {
return supportLine(event.background?.task_name, 80) || "background";
}
const status = event.http?.status;
return `http ${event.http?.method ?? ""} ${status ?? ""}`
.replace(/\s+/g, " ")
.trim();
}
/** The most frequent warn/error actions in the problems ring. */
export function summariseProblemEvents(
events: readonly WideEvent[],
limit: number = SUPPORT_REPORT_TOP_ERRORS,
): SupportReportFacts["errors"]["top"] {
const groups = new Map<
string,
{
action: string;
level: string;
count: number;
lastSeen: string;
errorClass: string;
}
>();
for (const event of events) {
const action = eventActionName(event);
const key = `${event.level}\u0000${action}`;
const existing = groups.get(key);
if (!existing) {
groups.set(key, {
action,
level: event.level,
count: 1,
lastSeen: event.timestamp,
errorClass: eventErrorClass(event),
});
continue;
}
existing.count += 1;
if (event.timestamp > existing.lastSeen) {
existing.lastSeen = event.timestamp;
existing.errorClass = eventErrorClass(event);
}
}
return [...groups.values()]
.sort((a, b) => b.count - a.count || b.lastSeen.localeCompare(a.lastSeen))
.slice(0, limit);
}
// ─── Collect ─────────────────────────────────────────────────────────────
function iso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
function runFact(run: LastQueueRun | null): QueueRunFact | null {
if (!run) return null;
return {
at: run.at,
state: run.state,
errorClass: run.error ? errorClassOf(run.error) : null,
};
}
function yesNo(value: boolean): string {
return value ? "yes" : "no";
}
function envSet(name: string): boolean {
return (process.env[name] ?? "").trim().length > 0;
}
async function readDatabaseFacts(): Promise<SupportReportFacts["database"]> {
const out: SupportReportFacts["database"] = {
version: null,
timezone: null,
sizeBytes: null,
lastMigration: null,
lastMigrationAt: null,
unfinishedMigrations: null,
};
try {
const [row] = await prisma.$queryRaw<
Array<{ version: string; timezone: string; size: bigint }>
>`
SELECT current_setting('server_version') AS version,
current_setting('TimeZone') AS timezone,
pg_database_size(current_database()) AS size
`;
if (row) {
out.version = row.version;
out.timezone = row.timezone;
out.sizeBytes = Number(row.size);
}
} catch {
/* reported as unknown */
}
try {
const [last] = await prisma.$queryRaw<
Array<{ migration_name: string; finished_at: Date | null }>
>`
SELECT migration_name, finished_at
FROM _prisma_migrations
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL
ORDER BY finished_at DESC, migration_name DESC
LIMIT 1
`;
const [unfinished] = await prisma.$queryRaw<Array<{ count: number }>>`
SELECT count(*)::int AS count
FROM _prisma_migrations
WHERE finished_at IS NULL AND rolled_back_at IS NULL
`;
out.lastMigration = last?.migration_name ?? null;
out.lastMigrationAt = iso(last?.finished_at);
out.unfinishedMigrations = unfinished?.count ?? null;
} catch {
/* no migrations table: reported as unknown */
}
return out;
}
async function readNewestFinishedJobAt(): Promise<string | null> {
try {
const [row] = await prisma.$queryRaw<Array<{ at: Date | null }>>`
SELECT max(completed_on) AS at FROM pgboss.job WHERE state = 'completed'
`;
return iso(row?.at);
} catch {
return null;
}
}
/** Read everything the report shows. Accounts leave here as numbers only. */
export async function collectSupportReportFacts(
now: Date = new Date(),
): Promise<{ facts: SupportReportFacts; context: SupportRedactionContext }> {
const users = await prisma.user.findMany({
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: {
id: true,
username: true,
email: true,
fullName: true,
aiUsageLevel: true,
},
});
const accountOf = new Map<string, number>();
users.forEach((u, i) => accountOf.set(u.id, i + 1));
const knownValues: string[] = [];
for (const u of users) {
knownValues.push(u.id, u.username);
if (u.email) knownValues.push(u.email);
if (u.fullName) knownValues.push(u.fullName);
}
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
glitchtipEnabled: true,
glitchtipDsn: true,
webPushVapidPublicKey: true,
webPushVapidPrivateKeyEncrypted: true,
adminAiKeyEncrypted: true,
adminCodexConnectionStatus: true,
aiUsageDefaultLevel: true,
aiUsageMaxLevel: true,
},
});
const providerTypes = await prisma.user.groupBy({
by: ["aiProvider"],
_count: { _all: true },
});
const config: Array<[string, string]> = [
["Secure cookie flag", yesNo(shouldEmitSecureCookie())],
["OIDC sign-in", yesNo(isOidcConfigured())],
["OIDC only", yesNo(isOidcOnly())],
["Loki", yesNo(Boolean(getLoggingConfig().lokiEndpoint))],
[
"GlitchTip",
yesNo(Boolean(appSettings?.glitchtipEnabled && appSettings.glitchtipDsn)),
],
["Off-host backup", yesNo(offhostBackupConfigured())],
[
"APNs",
yesNo(
envSet("APNS_KEY_ID") &&
envSet("APNS_TEAM_ID") &&
envSet("APNS_BUNDLE_ID") &&
(envSet("APNS_KEY_B64") ||
envSet("APNS_KEY") ||
envSet("APNS_KEY_FILE")),
),
],
[
"Web Push (VAPID)",
yesNo(
Boolean(
appSettings?.webPushVapidPublicKey &&
appSettings.webPushVapidPrivateKeyEncrypted,
) ||
(envSet("VAPID_PUBLIC_KEY") && envSet("VAPID_PRIVATE_KEY")),
),
],
["SMTP", yesNo(isEmailConfigured())],
["GeoLite2 databases", yesNo(offlineGeoReady())],
[
"NOTIFICATION_PRIVATE_ORIGINS entries",
String(configuredNotificationPrivateOrigins().size),
],
["Operator AI key", yesNo(Boolean(appSettings?.adminAiKeyEncrypted))],
[
"Central Codex connected",
yesNo(appSettings?.adminCodexConnectionStatus === "connected"),
],
[
"AI provider types chosen by accounts",
providerTypes
.filter((row) => row.aiProvider)
.map((row) => `${supportLine(row.aiProvider, 30)} ${row._count._all}`)
.sort()
.join(", ") || "none",
],
["Demo mode", yesNo(process.env.DEMO_MODE === "true")],
];
const workerStatus = getWorkerStatus();
const [database, failing, newestFinishedJobAt] = await Promise.all([
readDatabaseFacts(),
readFailingQueues(JOB_FAILURE_WINDOW_HOURS),
readNewestFinishedJobAt(),
]);
// Connections: one health read per account, capped.
const readUsers = users.slice(0, SUPPORT_REPORT_ACCOUNT_CAP);
const connectionGroups = new Map<
string,
{ provider: string; state: string; reason: string; accounts: number[] }
>();
for (const user of readUsers) {
let health;
try {
health = await loadConnectionHealth(user.id);
} catch {
continue;
}
for (const key of CONNECTION_KEYS) {
const h = health[key];
if (!h) continue;
const groupKey = `${key}|${h.state}|${h.reason}`;
const group = connectionGroups.get(groupKey) ?? {
provider: key,
state: h.state,
reason: h.reason,
accounts: [],
};
group.accounts.push(accountOf.get(user.id) ?? 0);
connectionGroups.set(groupKey, group);
}
}
const incidentRows = await prisma.integrationWatch.findMany({
where: { incidentReason: { not: null } },
select: {
userId: true,
integration: true,
incidentReason: true,
incidentOpenedAt: true,
alertedAt: true,
},
orderBy: { incidentOpenedAt: "desc" },
take: 100,
});
const providerRows = await prisma.providerHealth.findMany({
select: {
providerType: true,
lastResult: true,
lastStatus: true,
lastFailureAt: true,
nextRetryAt: true,
},
});
const providers = new Map<
string,
SupportReportFacts["ai"]["providers"][number]
>();
for (const row of providerRows) {
const p = providers.get(row.providerType) ?? {
provider: row.providerType,
ok: 0,
failing: 0,
authFailed: 0,
paused: 0,
lastFailureAt: null,
lastStatus: null,
};
if (row.lastResult === "ok") p.ok += 1;
else if (row.lastResult === "auth_failed") p.authFailed += 1;
else p.failing += 1;
if (row.nextRetryAt && row.nextRetryAt.getTime() > now.getTime()) {
p.paused += 1;
}
const failedAt = iso(row.lastFailureAt);
if (failedAt && (!p.lastFailureAt || failedAt > p.lastFailureAt)) {
p.lastFailureAt = failedAt;
p.lastStatus = row.lastStatus;
}
providers.set(row.providerType, p);
}
const levelCounts = new Map<string, number>();
for (const u of users) {
const level = u.aiUsageLevel ?? "instance default";
levelCounts.set(level, (levelCounts.get(level) ?? 0) + 1);
}
const since = new Date(
now.getTime() - SUPPORT_REPORT_NOTIFICATION_DAYS * 24 * 60 * 60 * 1000,
);
const [attemptCounts, reasonCounts] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
where: { createdAt: { gte: since } },
_count: { _all: true },
}),
prisma.pushAttempt.groupBy({
by: ["channel", "reason"],
where: { createdAt: { gte: since }, result: { not: "ok" } },
_count: { _all: true },
}),
]);
const channels = new Map<
string,
SupportReportFacts["notifications"][number]
>();
const channelOf = (channel: string) => {
const c = channels.get(channel) ?? {
channel,
ok: 0,
error: 0,
skipped: 0,
topReasons: [],
};
channels.set(channel, c);
return c;
};
for (const row of attemptCounts) {
const c = channelOf(row.channel);
if (row.result === "ok") c.ok += row._count._all;
else if (row.result === "error") c.error += row._count._all;
else c.skipped += row._count._all;
}
for (const row of reasonCounts) {
channelOf(row.channel).topReasons.push([
supportLine(row.reason ?? "unspecified", 60),
row._count._all,
]);
}
for (const c of channels.values()) {
c.topReasons = c.topReasons.sort((a, b) => b[1] - a[1]).slice(0, 3);
}
const [
localAggregate,
localCopies,
offhostRows,
lastLocalRun,
lastOffhostRun,
lastDrill,
] = await Promise.all([
prisma.dataBackup.aggregate({ _max: { createdAt: true } }),
prisma.dataBackup.count(),
prisma.offhostBackupState.findMany({ select: { lastSuccessAt: true } }),
readLastQueueRun(DATA_BACKUP_QUEUE),
readLastQueueRun(OFFHOST_BACKUP_QUEUE),
readLastQueueRun(RESTORE_DRILL_QUEUE),
]);
const offhostDates = offhostRows
.map((r) => r.lastSuccessAt)
.filter((d): d is Date => d !== null)
.map((d) => d.toISOString())
.sort();
const problemEvents = readLogBuffer({ ring: "problems" });
const facts: SupportReportFacts = {
generatedAt: now.toISOString(),
build: {
version:
process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version,
buildSha: process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null,
builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null,
node: process.version,
processType: getProcessType(),
heapLimitMb: Math.round(
getHeapStatistics().heap_size_limit / 1024 / 1024,
),
uptimeSeconds: Math.round(process.uptime()),
},
database,
config,
jobs: {
workerInThisProcess: shouldRunWorker(),
workerRunning: workerStatus.running,
workerLastHeartbeat: workerStatus.lastHeartbeat,
newestFinishedJobAt,
failing:
failing === null
? null
: failing.map((q) => ({
queue: q.queue,
failures: q.failures,
lastFailedAt: q.lastFailedAt,
errorClass: errorClassOf(q.lastError),
})),
},
accounts: { total: users.length, read: readUsers.length },
connections: [...connectionGroups.values()].sort(
(a, b) =>
a.provider.localeCompare(b.provider) ||
a.state.localeCompare(b.state) ||
a.reason.localeCompare(b.reason),
),
incidents: incidentRows.map((row) => ({
provider: row.integration,
reason: row.incidentReason ?? "",
openedAt: iso(row.incidentOpenedAt),
announced: row.alertedAt !== null,
account: accountOf.get(row.userId) ?? null,
})),
ai: {
defaultLevel: appSettings?.aiUsageDefaultLevel ?? null,
maxLevel: appSettings?.aiUsageMaxLevel ?? null,
levels: [...levelCounts.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
),
providers: [...providers.values()].sort((a, b) =>
a.provider.localeCompare(b.provider),
),
},
notifications: [...channels.values()].sort((a, b) =>
a.channel.localeCompare(b.channel),
),
backups: {
newestLocalAt: iso(localAggregate._max.createdAt),
localCopies,
lastLocalRun: runFact(lastLocalRun),
offhostConfigured: offhostBackupConfigured(),
offhostNewestAt: offhostDates.at(-1) ?? null,
offhostOldestAt: offhostDates[0] ?? null,
offhostAccounts: offhostRows.length,
offhostNeverStored: offhostRows.length - offhostDates.length,
lastOffhostRun: runFact(lastOffhostRun),
lastRestoreDrill: runFact(lastDrill),
},
errors: {
ringSize: problemEvents.length,
ringMax: PROBLEMS_BUFFER_MAX,
top: summariseProblemEvents(problemEvents),
},
};
return { facts, context: { knownValues } };
}
// ─── Render ──────────────────────────────────────────────────────────────
function or(value: string | number | null | undefined, fallback = "unknown") {
return value === null || value === undefined || value === ""
? fallback
: String(value);
}
function megabytes(bytes: number | null): string {
return bytes === null ? "unknown" : `${Math.round(bytes / 1024 / 1024)} MB`;
}
function runLine(run: QueueRunFact | null): string {
if (!run) return "no run on record";
return run.errorClass
? `${run.state} at ${run.at} (${run.errorClass})`
: `${run.state} at ${run.at}`;
}
function accountList(accounts: readonly number[]): string {
const sorted = [...accounts].sort((a, b) => a - b);
const shown = sorted.slice(0, 10).map((n) => `Account ${n}`);
const more = sorted.length - shown.length;
return more > 0 ? `${shown.join(", ")} and ${more} more` : shown.join(", ");
}
/** The report as plain text, before redaction. */
export function renderSupportReport(facts: SupportReportFacts): string {
const lines: string[] = [];
const section = (title: string) => {
if (lines.length > 0) lines.push("");
lines.push(`## ${title}`);
};
const row = (label: string, value: string | number | null) =>
lines.push(`- ${label}: ${or(value)}`);
lines.push("# HealthLog support report");
lines.push(`Generated: ${facts.generatedAt}`);
section("Build");
row("Version", facts.build.version);
row("Build SHA", facts.build.buildSha);
row("Built at", facts.build.builtAt);
row("Node", facts.build.node);
row("Process type", facts.build.processType);
row("Heap limit", `${facts.build.heapLimitMb} MB`);
row(
"Uptime",
`${Math.floor(facts.build.uptimeSeconds / 3600)} h ${Math.floor((facts.build.uptimeSeconds % 3600) / 60)} min`,
);
section("Database");
row("PostgreSQL", facts.database.version);
row("Server time zone", facts.database.timezone);
row("Size", megabytes(facts.database.sizeBytes));
row(
"Last migration",
facts.database.lastMigration
? `${facts.database.lastMigration} (${or(facts.database.lastMigrationAt)})`
: null,
);
row("Unfinished migrations", facts.database.unfinishedMigrations);
section("Configuration");
for (const [label, value] of facts.config) row(label, value);
section("Jobs");
row("Worker in this process", yesNo(facts.jobs.workerInThisProcess));
if (facts.jobs.workerInThisProcess) {
row("Worker running", yesNo(facts.jobs.workerRunning));
row("Worker last heartbeat", facts.jobs.workerLastHeartbeat);
}
row("Newest finished job", facts.jobs.newestFinishedJobAt);
if (facts.jobs.failing === null) {
row(
`Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`,
"queue tables not readable",
);
} else if (facts.jobs.failing.length === 0) {
row(`Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, "none");
} else {
lines.push(`- Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h):`);
for (const q of facts.jobs.failing) {
lines.push(
` - ${supportLine(q.queue, 60)}: ${q.failures} failed, last ${q.lastFailedAt} (${q.errorClass})`,
);
}
}
section("Connections");
row(
"Accounts",
facts.accounts.read < facts.accounts.total
? `${facts.accounts.total} (connections read for the oldest ${facts.accounts.read})`
: facts.accounts.total,
);
if (facts.connections.length === 0) {
row("Connected sources", "none");
} else {
for (const c of facts.connections) {
const who = c.state === "ok" ? "" : ` (${accountList(c.accounts)})`;
lines.push(
`- ${c.provider}: ${c.state} / ${c.reason}: ${c.accounts.length}${who}`,
);
}
}
if (facts.incidents.length === 0) {
row("Open watchdog incidents", "none");
} else {
lines.push("- Open watchdog incidents:");
for (const i of facts.incidents) {
lines.push(
` - ${supportLine(i.provider, 40)}: ${supportLine(i.reason, 40)} since ${or(i.openedAt)}, ${i.announced ? "announced" : "not announced"}${i.account ? `, Account ${i.account}` : ""}`,
);
}
}
section("AI");
row("Usage level default", facts.ai.defaultLevel);
row("Usage level ceiling", facts.ai.maxLevel);
row(
"Accounts by usage level",
facts.ai.levels.map(([level, n]) => `${level} ${n}`).join(", ") || "none",
);
if (facts.ai.providers.length === 0) {
row("Provider ledger", "empty");
} else {
lines.push("- Provider ledger (accounts):");
for (const p of facts.ai.providers) {
const last = p.lastFailureAt
? `, last failure ${p.lastFailureAt}${p.lastStatus ? ` (HTTP ${p.lastStatus})` : ""}`
: "";
lines.push(
` - ${supportLine(p.provider, 30)}: ok ${p.ok}, failing ${p.failing}, credential refused ${p.authFailed}, paused ${p.paused}${last}`,
);
}
}
section(`Notifications (${SUPPORT_REPORT_NOTIFICATION_DAYS} days)`);
if (facts.notifications.length === 0) {
row("Delivery attempts", "none");
} else {
for (const c of facts.notifications) {
const reasons =
c.topReasons.length > 0
? `; ${c.topReasons.map(([r, n]) => `${r} ${n}`).join(", ")}`
: "";
lines.push(
`- ${supportLine(c.channel, 20)}: ok ${c.ok}, failed ${c.error}, skipped ${c.skipped}${reasons}`,
);
}
}
section("Backups");
row("Newest in-database copy", facts.backups.newestLocalAt ?? "none");
row("In-database copies", facts.backups.localCopies);
row("Last weekly run", runLine(facts.backups.lastLocalRun));
row("Off-host configured", yesNo(facts.backups.offhostConfigured));
if (facts.backups.offhostAccounts > 0) {
row("Off-host newest", facts.backups.offhostNewestAt ?? "none");
row(
"Off-host oldest account copy",
facts.backups.offhostOldestAt ?? "none",
);
row(
"Off-host accounts never stored",
`${facts.backups.offhostNeverStored} of ${facts.backups.offhostAccounts}`,
);
}
row("Last off-host run", runLine(facts.backups.lastOffhostRun));
row("Last restore drill", runLine(facts.backups.lastRestoreDrill));
section("Recent warnings and errors (this process)");
row(
"Problems ring",
`${facts.errors.ringSize} of ${facts.errors.ringMax} entries`,
);
if (facts.errors.top.length === 0) {
row("Top actions", "none");
} else {
for (const e of facts.errors.top) {
lines.push(
`- ${e.level} ${e.action}: ${e.count}x, last ${e.lastSeen}, ${e.errorClass}`,
);
}
}
return `${lines.join("\n")}\n`;
}
/** Facts to finished, redacted text. */
export function buildSupportReportText(
facts: SupportReportFacts,
context: SupportRedactionContext,
): string {
return redactSupportText(renderSupportReport(facts), context);
}
@@ -0,0 +1,152 @@
/**
* `GET /api/admin/support-report` against real Postgres.
*
* - admin only, through the real `apiHandler` and `requireAdmin`;
* - the raw reads (server version, time zone, size, the migrations table,
* the queue) and the group-bys run against the real schema;
* - accounts with distinctive usernames, emails and names appear only as
* "Account n", and their ids never.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { GET } from "@/app/api/admin/support-report/route";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
}
function call() {
return GET(new NextRequest("http://localhost/api/admin/support-report"));
}
describe("GET /api/admin/support-report (integration)", () => {
it("refuses a signed-in non-admin", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "plainmember", email: "plain.member@example.test" },
});
await signIn(user.id);
const res = await call();
expect(res.status).toBe(403);
});
it("answers a redacted report built from the real schema", async () => {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "operatorwithaname",
email: "operator.distinct@example.test",
fullName: "Ottilie Operatorson",
role: "ADMIN",
},
});
const member = await prisma.user.create({
data: {
username: "memberwithaname",
email: "member.distinct@example.test",
fullName: "Mathilda Memberson",
},
});
await prisma.providerHealth.create({
data: {
userId: member.id,
providerType: "LOCAL",
lastResult: "auth_failed",
lastStatus: 401,
consecutiveFailures: 3,
lastFailureAt: new Date(),
nextRetryAt: new Date(Date.now() + 3_600_000),
},
});
await prisma.integrationWatch.create({
data: {
userId: member.id,
integration: "nightscout",
incidentReason: "unexpected_silence",
incidentOpenedAt: new Date(),
},
});
await prisma.pushAttempt.create({
data: {
userId: member.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "error",
reason: "http_500",
},
});
await signIn(admin.id);
const res = await call();
expect(res.status).toBe(200);
const body = await res.json();
const text: string = body.data.text;
expect(text).toMatch(/- PostgreSQL: \d+/);
expect(text).toMatch(/- Server time zone: \S+/);
expect(text).toMatch(/- Size: \d+ MB/);
expect(text).toMatch(/- Last migration: \d{4}_\w+ \(/);
expect(text).toContain("- Unfinished migrations: 0");
expect(text).toContain(
"- LOCAL: ok 0, failing 0, credential refused 1, paused 1",
);
expect(text).toContain("nightscout: unexpected_silence since");
expect(text).toContain("Account 2");
expect(text).toContain("- NTFY: ok 0, failed 1, skipped 0; http_500 1");
expect(text).toContain("- Accounts: 2");
for (const value of [
admin.id,
member.id,
admin.username,
member.username,
admin.email!,
member.email!,
"Ottilie",
"Mathilda",
]) {
expect(text.toLowerCase(), value).not.toContain(value.toLowerCase());
}
});
});