feat(integrations): remove the moodLog integration

Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 11:36:18 +02:00
1 parent 4ed8c8f39b
commit d0b45e36b3
106 files changed
+379 -3326

No files matched your search

+2 -2
View File
@@ -50,8 +50,8 @@ describe("toCSV", () => {
// Spreadsheet formula injection (OWASP CSV injection): text cells
// starting with a formula trigger are neutralised with a leading `'`.
// Third-party text reaches these cells (mood notes via the moodLog
// webhook), so a crafted payload must open as literal text.
// Third-party text reaches these cells (notes carried in on an import),
// so a crafted payload must open as literal text.
it("neutralises formula prefixes in text cells", () => {
const records = [
{ note: '=HYPERLINK("http://evil","x")' },
-106
View File
@@ -1,106 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
encryptMoodLogSecret,
readMoodLogSecret,
isLegacyPlaintext,
rotateLegacyMoodLogSecrets,
} from "../moodlog-secret";
const ORIGINAL_ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
beforeEach(() => {
process.env.ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(() => {
if (ORIGINAL_ENCRYPTION_KEY === undefined) delete process.env.ENCRYPTION_KEY;
else process.env.ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY;
});
// V3 audit STILL-V2-C-2: mood_log_webhook_secret was stored in plaintext.
// The new contract encrypts at rest with AES-256-GCM and tolerates legacy
// plaintext rows during the transition window.
describe("moodLog secret encrypt-at-rest", () => {
it("encrypts and decrypts a generated secret round-trip", () => {
const plaintext = "mb_" + "f".repeat(64);
const encrypted = encryptMoodLogSecret(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(readMoodLogSecret(encrypted)).toBe(plaintext);
});
it("treats unrecognised values as legacy plaintext (transitional grace)", () => {
expect(readMoodLogSecret("mb_legacyplaintextsecret")).toBe(
"mb_legacyplaintextsecret",
);
});
it("returns null for null/empty stored values", () => {
expect(readMoodLogSecret(null)).toBeNull();
expect(readMoodLogSecret("")).toBeNull();
});
it("isLegacyPlaintext detects rotation status correctly", () => {
const enc = encryptMoodLogSecret("mb_test");
expect(isLegacyPlaintext(enc)).toBe(false);
expect(isLegacyPlaintext("mb_test")).toBe(true);
expect(isLegacyPlaintext(null)).toBe(false);
});
it("throws when an envelope-shaped row fails to decrypt (tamper or stale key)", () => {
// `<keyId>.<payload>` shape but the keyId is unknown to the live
// config — must surface as an error, not silently return the
// ciphertext as if it were the plaintext secret.
expect(() => readMoodLogSecret("v9.AAAAAAAAAAAAAAAAAAAAAAA=")).toThrow();
});
it("treats envelope-shaped tampered ciphertext as a hard failure", () => {
// Round-trip an encrypted value, then flip a byte in the payload.
// The shape stays envelope-like, so decrypt must throw (AES-GCM
// auth-tag mismatch) instead of falling back to plaintext.
const enc = encryptMoodLogSecret("mb_test");
const dot = enc.indexOf(".");
const tampered = `${enc.slice(0, dot + 1)}AA${enc.slice(dot + 3)}`;
expect(() => readMoodLogSecret(tampered)).toThrow();
});
it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => {
const enc = encryptMoodLogSecret("mb_alreadyencrypted");
const rows = [
{ id: "u1", moodLogWebhookSecret: "mb_legacy1" },
{ id: "u2", moodLogWebhookSecret: enc },
{ id: "u3", moodLogWebhookSecret: "mb_legacy3" },
{ id: "u4", moodLogWebhookSecret: null },
];
const updates: Array<{ id: string; encrypted: string }> = [];
const rotated = await rotateLegacyMoodLogSecrets({
findLegacy: async () => rows,
rotate: async (id, encrypted) => {
updates.push({ id, encrypted });
},
});
expect(rotated).toBe(2);
expect(updates.map((u) => u.id).sort()).toEqual(["u1", "u3"]);
for (const update of updates) {
// Each rotation must produce a value that decrypts back to the
// original legacy plaintext — no data loss.
const original = rows.find(
(r) => r.id === update.id,
)!.moodLogWebhookSecret;
expect(readMoodLogSecret(update.encrypted)).toBe(original);
}
// Re-running on the now-encrypted store rotates nothing.
const next = await rotateLegacyMoodLogSecrets({
findLegacy: async () => [
{ id: "u1", moodLogWebhookSecret: updates[0].encrypted },
{ id: "u2", moodLogWebhookSecret: enc },
],
rotate: async () => {
throw new Error("should not be called");
},
});
expect(next).toBe(0);
});
});
@@ -41,14 +41,6 @@ describe("SSRF requirePublicHost pin inventory", () => {
// v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator
// webhook pins too, so the full inventory is CI-guarded and a future edit
// that drops one of these reds here rather than shipping silently.
it("moodLog push pins the user-supplied webhook outbound", () => {
expect(read("moodlog/push.ts")).toMatch(/requirePublicHost:\s*true/);
});
it("moodLog sync pins the user-supplied webhook outbound", () => {
expect(read("moodlog/sync.ts")).toMatch(/requirePublicHost:\s*true/);
});
it("ntfy sender pins the user/operator-supplied server outbound", () => {
expect(read("notifications/senders/ntfy.ts")).toMatch(
/requirePublicHost:\s*true/,
+1 -1
View File
@@ -170,7 +170,7 @@ const SIDE_EFFECT_TAGS = new Set([
function parseTagList(raw: string | null): string[] {
if (!raw) return [];
// The MoodEntry.tags column carries either a JSON array (modern
// mood-form writes) or a comma-separated list (legacy moodLog
// mood-form writes) or a comma-separated list (legacy imported
// imports). Be permissive.
try {
const parsed = JSON.parse(raw);
+1 -1
View File
@@ -35,7 +35,7 @@
* counting it as good control.
*
* 3. **No fallback when timestamps don't pair (v1.4.15 fix).** Withings
* imports and moodLog.app imports each write sys + dia as separate
* imports each write sys + dia as separate
* Measurement rows that ought to share a `measuredAt` to the second
* — but rounding through different timezones can drift them by
* minutes. The 5-minute window was tight; data imported with
-4
View File
@@ -6,7 +6,6 @@ export interface GlobalServiceAvailability {
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvailability> {
@@ -18,7 +17,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
});
@@ -27,7 +25,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: settings?.ntfyGlobal ?? true,
webPushGlobal: settings?.webPushGlobal ?? true,
apiGlobal: settings?.apiGlobal ?? true,
moodLogGlobal: settings?.moodLogGlobal ?? true,
};
} catch {
getEvent()?.addWarning("Failed to load app settings, using defaults");
@@ -36,7 +33,6 @@ export async function getGlobalServiceAvailability(): Promise<GlobalServiceAvail
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
};
}
}
@@ -32,9 +32,8 @@ const NON_SUFFIX_ENCRYPTED: ReadonlySet<string> = new Set([
"lastError",
// CoachMessage / InsightNarrative
"encryptedContent",
// User (Telegram + moodLog)
// User (Telegram)
"telegramBotToken",
"moodLogWebhookSecret",
]);
/**
-3
View File
@@ -55,9 +55,6 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "codexAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "codexRefreshTokenEncrypted", kind: "string" },
{ model: "User", field: "telegramBotToken", kind: "string" },
{ model: "User", field: "moodLogWebhookSecret", kind: "string" },
{ model: "User", field: "moodLogUrlEncrypted", kind: "string" },
{ model: "User", field: "moodLogApiKeyEncrypted", kind: "string" },
{ model: "User", field: "withingsClientIdEncrypted", kind: "string" },
{ model: "User", field: "withingsClientSecretEncrypted", kind: "string" },
{ model: "User", field: "whoopClientIdEncrypted", kind: "string" },
+4 -4
View File
@@ -468,7 +468,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
input({
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
{ integration: "nightscout", state: "parked" },
],
}),
t,
@@ -508,7 +508,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
{ integration: "nightscout", state: "parked" },
{ integration: "fitbit", state: "error_reauth" },
],
preventiveDue: [{ label: "Blood panel" }],
@@ -646,7 +646,7 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
{ integration: "nightscout", state: "parked" },
],
coachPlans: [plan()],
}),
@@ -784,7 +784,7 @@ describe("buildDailyDigest — S11 tension_window item", () => {
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
{ integration: "nightscout", state: "parked" },
],
tensionWindow: { partOfDay: "evening" },
}),
+1 -1
View File
@@ -99,7 +99,7 @@ export interface DailyDigestScore {
/** A broken integration, deterministically derived from `IntegrationStatus`. */
export interface DailyDigestSyncIssue {
/** Integration token (`withings`, `moodlog`, …). */
/** Integration token (`withings`, `whoop`, …). */
integration: string;
/** The failure state the row carries (`error_reauth`, `disconnected`, …). */
state: string;
+1 -1
View File
@@ -48,7 +48,7 @@ export interface ExportableRecord {
* (measurement values, mood scores) stay typed `number` all the way to
* this function, so a negative reading exports as `-5.2`, never
* `'-5.2`. The text columns are where third-party content can land —
* mood `note` originates from the moodLog webhook, measurement `notes`
* mood `note` originates from user input, measurement `notes`
* from arbitrary clients — and a crafted `=HYPERLINK(...)` /
* `=cmd|...` there must not execute when an operator opens the export.
*/
@@ -48,7 +48,6 @@ const appSettings = {
webPushVapidPrivateKeyEncrypted: "encrypted-private-key",
webPushVapidSubject: "mailto:ops@example.test",
apiGlobal: false,
moodLogGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-id",
+1 -1
View File
@@ -669,7 +669,7 @@ export async function buildAchievementsResult(
}),
// v1.4.18 — mood entries feed the new mood badges + the
// entry-streak/consistent-month engagement metrics. Synced from
// moodLog.app or entered directly; we intentionally include all
// imported or entered directly; we intentionally include all
// sources because consistency-of-tracking is what the badge
// rewards.
prisma.moodEntry.findMany({
@@ -87,18 +87,18 @@ describe("formatAdminAlertPayload — Withings re-auth", () => {
});
});
describe("formatAdminAlertPayload — moodLog transient", () => {
it("uses 'moodLog' display name and the 'investigate the upstream service.' action", () => {
describe("formatAdminAlertPayload — non-Withings transient", () => {
it("uses the provider's display name and the 'investigate the upstream service.' action", () => {
const out = formatAdminAlertPayload({
userId: "u-2",
integration: "moodlog",
integration: "nightscout",
kind: "transient",
message: "moodLog sync HTTP 502",
message: "Nightscout sync HTTP 502",
errorCode: "http_502",
consecutiveFailures: 4,
subjectLabel: "user@example.com",
});
expect(out.title).toBe("moodLog sync failing for user@example.com");
expect(out.title).toBe("Nightscout sync failing for user@example.com");
expect(out.message).toContain("Action: investigate the upstream service.");
expect(out.message).toContain("(http_502)");
});
@@ -159,14 +159,14 @@ describe("formatAdminAlertPayload — metadata payload", () => {
const withoutCode = formatAdminAlertPayload({
userId: "u-6",
integration: "moodlog",
integration: "nightscout",
kind: "transient",
message: "blip",
errorCode: undefined,
consecutiveFailures: 7,
});
expect(withoutCode.metadata).toEqual({
integration: "moodlog",
integration: "nightscout",
affectedUserId: "u-6",
consecutiveFailures: 7,
errorCode: null,
@@ -223,9 +223,9 @@ describe("recordSyncFailure — at threshold", () => {
} as never);
await recordSyncFailure({
userId: "u1",
integration: "moodlog",
integration: "nightscout",
kind: "transient",
message: "moodLog sync HTTP 502",
message: "Nightscout sync HTTP 502",
});
expect(dispatchNotification).not.toHaveBeenCalled();
});
@@ -479,7 +479,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await markDisconnected("u1", "moodlog");
await markDisconnected("u1", "nightscout");
const args = vi.mocked(prisma.integrationStatus.upsert).mock.calls[0][0];
expect(args.update).toEqual({
state: "disconnected",
@@ -35,7 +35,7 @@ describe("resolveSyncVerdict — the ladder", () => {
state: "error_transient",
lastSuccessAt: ago(55 * DAY),
lastAttemptAt,
cadence: INTEGRATION_CADENCE.moodlog,
cadence: INTEGRATION_CADENCE.nightscout,
now: NOW,
});
expect(result.verdict).toBe("stalled");
+3 -4
View File
@@ -35,10 +35,9 @@ export type {
} from "./sync-verdict";
/**
* The `MeasurementSource` each sync integration's rows carry. `moodlog` is
* absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no
* per-metric measurement freshness. `strava` is absent from the measurement
* map for the same reason and picks up its `WORKOUTS` entry below.
* The `MeasurementSource` each sync integration's rows carry. `strava` is
* absent — it writes `Workout` rows, not `Measurement` rows — and picks up its
* `WORKOUTS` entry below.
*/
export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
+6 -11
View File
@@ -1,8 +1,7 @@
/**
* Per-(user, integration) sync-status bookkeeping.
*
* v1.4.15 Phase B2 introduces three responsibilities for every Withings /
* moodLog sync attempt:
* v1.4.15 Phase B2 introduces three responsibilities for every sync attempt:
*
* 1. Record success / failure timestamps so Settings → Integrations
* can show "last sync 23 minutes ago" instead of guessing from
@@ -23,9 +22,8 @@
* sync entry-point still attempts on the next
* run (network blip, 5xx, etc.).
* error_reauth → refresh-token grant has revoked. `getValidToken`
* / `syncMoodLogEntries` short-circuit until the
* user reconnects. Cleared by markReconnected()
* on the OAuth callback.
* short-circuits until the user reconnects. Cleared
* by markReconnected() on the OAuth callback.
* disconnected → user clicked "Disconnect". Set explicitly by
* the disconnect routes; clears any prior error
* state.
@@ -53,8 +51,7 @@ export type IntegrationKey =
// `fitbit` transport, which sunsets Sept 2026.
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava"
| "moodlog";
| "strava";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -311,9 +308,8 @@ export async function isReauthRequired(
/**
* Record a successful sync. Resets the failure counter, clears any
* prior error message, and (importantly) flips state back to
* `connected` even from `error_reauth` — the moodLog flow re-enters
* after the user re-supplies the apiKey, the Withings flow after the
* OAuth callback writes a new refresh token.
* `connected` even from `error_reauth` — the Withings flow re-enters
* after the OAuth callback writes a new refresh token.
*/
export async function recordSyncSuccess(
userId: string,
@@ -862,7 +858,6 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
const FAILURE_KIND_COPY: Record<
+2 -3
View File
@@ -115,14 +115,13 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
oura: POLLED_CADENCE,
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
moodlog: POLLED_CADENCE,
};
export interface SyncVerdictInput {
/**
* Whether a live connection exists (an OAuth token, a connection row). Pass
* `undefined` for providers that carry no connection concept — moodLog is
* configured by URL alone — so the ladder falls through to `configured`.
* `undefined` for providers that carry no connection concept, so the ladder
* falls through to `configured`.
*/
connected?: boolean;
/** Whether credentials / a URL are stored for this provider. */
+1 -1
View File
@@ -51,7 +51,7 @@ export const GEO_BACKFILL_WINDOW_DAYS = 30;
/**
* v1.4.37 — pg-boss queue name + cron expression for the recurring
* backfill. Offset to :40 every hour so the schedule does not collide
* with the existing :00 / :15 / :30 Withings + moodlog crons that
* with the existing :00 / :15 / :30 Withings crons that
* already crowd the top of the hour. The helper is idempotent + the
* batch cap is per-call, so an hourly cadence is the right balance
* between freshness of the admin sign-in "Standort" column and the
+1 -1
View File
@@ -61,7 +61,7 @@ export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
/**
* Cron schedule. Hourly at :05 so the tick doesn't collide with the
* top-of-the-hour reminder-check (:00) or the moodlog-sync (:30)
* top-of-the-hour reminder-check (:00) or the rollup fold (:30)
* cadences. Europe/Berlin matches the rest of the worker's schedules.
*/
export const INTAKE_AUTO_SKIP_CRON = "5 * * * *";
-5
View File
@@ -20,7 +20,6 @@ import { assertSubsystemEnabled } from "@/lib/process-type";
import { DATABASE_URL, workerLog } from "./reminder/shared";
import {
registerShutdownHandlers,
rotateLegacyMoodLogSecretsAtBoot,
reconcileImportJobsAtBoot,
probeIntegrationStatusAtBoot,
} from "./reminder/worker-lifecycle";
@@ -60,10 +59,6 @@ export async function startReminderWorker() {
setGlobalBoss(boss);
markWorkerStarted();
// V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration for any
// plaintext mood_log_webhook_secret rows. Idempotent.
await rotateLegacyMoodLogSecretsAtBoot();
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT.
registerShutdownHandlers(boss);
-70
View File
@@ -1,70 +0,0 @@
/**
* moodLog hourly sync handler.
*
* Extracted from reminder-worker.ts, which owns the queue names, cron
* schedules, and boss.work registrations.
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { syncMoodLogEntries } from "@/lib/moodlog/sync";
import { getWorkerPrisma } from "./shared";
export interface MoodLogSyncPayload {
triggeredAt: string;
}
/**
* Fallback polling for moodLog data.
* Syncs mood entries for all users with moodLog enabled.
*/
export async function handleMoodLogSync(jobs: Job<MoodLogSyncPayload>[]) {
void jobs;
await withBackgroundEvent("job.moodlog_sync", async (evt) => {
const prisma = getWorkerPrisma();
try {
// Check global toggle
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moodLogGlobal: true },
});
if (appSettings && !appSettings.moodLogGlobal) {
evt.addMeta("skipped", "global_toggle_disabled");
return;
}
const users = await prisma.user.findMany({
where: { moodLogEnabled: true },
select: { id: true },
});
if (users.length === 0) return;
let synced = 0;
let totalImported = 0;
for (const user of users) {
try {
const imported = await syncMoodLogEntries(user.id);
synced++;
totalImported += imported;
} catch (err) {
evt.addWarning(`Fallback sync failed for user ${user.id}: ${err}`);
}
}
evt.setBackground({
task_name: "job.moodlog_sync",
result: {
synced,
total: users.length,
entries_imported: totalImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
@@ -4,7 +4,7 @@
* Owns the third-party-provider sync queues — Withings (fallback / activity /
* sleep), WHOOP (recovery / sleep / workout / cycle + backfill + OAuth-state
* cleanup), Fitbit (sync + backfill + sleep repair + OAuth-state cleanup),
* Nightscout, Polar, Oura, MoodLog, and the two one-shot backfills
* Nightscout, Polar, Oura, and the two one-shot backfills
* (sleep-timeline, lab-biomarker) — plus their boot-time discovery enqueues.
*
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
@@ -107,7 +107,6 @@ import {
handleWhoopWorkoutSync,
handleWhoopCycleSync,
} from "./whoop-sync";
import { MoodLogSyncPayload, handleMoodLogSync } from "./moodlog-sync";
import {
WithingsOAuthStateCleanupPayload,
handleWithingsOAuthStateCleanup,
@@ -159,9 +158,6 @@ const WITHINGS_SLEEP_CRON = "15 * * * *"; // every hour at :15
// 20,30,35,50) staggered off every other hourly sync tick.
const WITHINGS_ECG_SYNC_CRON = "41 * * * *"; // every hour at :41
const MOODLOG_SYNC_QUEUE = "moodlog-sync";
const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots
// at :20 between the audit-log cleanup (:15) and the mood-reminder
// cleanup (:25), inside the existing 02:xx-03:xx maintenance window.
@@ -274,7 +270,6 @@ const allQueues = [
WITHINGS_ACTIVITY_QUEUE,
WITHINGS_SLEEP_QUEUE,
WITHINGS_ECG_SYNC_QUEUE,
MOODLOG_SYNC_QUEUE,
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for
// recovery / sleep / workout; cycle is poll-only (no WHOOP webhook).
@@ -338,7 +333,6 @@ const schedules: ScheduleEntry[] = [
// v1.32.23 — hourly ECG catch-net (:41) so watch-only accounts pull their
// strips without a webhook or a scale.
[WITHINGS_ECG_SYNC_QUEUE, WITHINGS_ECG_SYNC_CRON],
[MOODLOG_SYNC_QUEUE, MOODLOG_SYNC_CRON],
[WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON],
// v1.11.0 — WHOOP poll-fallback crons. Recovery/sleep/workout catch
// dropped webhooks; cycle is the sole driver (no webhook). Staggered off
@@ -775,12 +769,6 @@ export async function registerIntegrationSyncQueues(
}
},
);
await boss.work<MoodLogSyncPayload>(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
handleMoodLogSync,
);
return allQueues;
}
@@ -446,8 +446,8 @@ const schedules: ScheduleEntry[] = [
[MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON],
// v1.4.46 — hourly auto-skip for medication intakes the user never
// marked. Cron `5 * * * *` slots off the top-of-the-hour
// reminder-check (:00) and the moodlog-sync (:30) so the three
// hourly ticks don't pile up on the same boss poll.
// reminder-check (:00) so the hourly ticks don't pile up on the same
// boss poll.
[INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON],
// v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep.
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON],
+1 -35
View File
@@ -2,7 +2,7 @@
* Reminder-worker boot lifecycle helpers.
*
* These run once per worker process around the queue/schedule/handler wiring
* in `reminder-worker.ts`: a graceful-shutdown signal binding plus three
* in `reminder-worker.ts`: a graceful-shutdown signal binding plus two
* fire-and-forget boot maintenance passes. None of them touch the pg-boss
* queue declarations, the `allQueues` array, the `schedules` table, or the
* `boss.work` bindings — those stay co-located in `reminder-worker.ts` so the
@@ -11,7 +11,6 @@
import type { PgBoss } from "pg-boss";
import { reconcileOrphanImportJobs } from "@/lib/jobs/apple-health-import-worker";
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
import { withBackgroundEvent } from "@/lib/logging/background";
@@ -43,39 +42,6 @@ export function registerShutdownHandlers(boss: PgBoss): void {
process.once("SIGINT", () => void onSignal("SIGINT"));
}
/**
* V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates any rows
* that still hold a plaintext mood_log_webhook_secret to the AES-256-GCM
* envelope. Idempotent — encrypted rows are skipped. Never throws; a failure
* is logged and boot continues.
*/
export async function rotateLegacyMoodLogSecretsAtBoot(): Promise<void> {
try {
const p = getWorkerPrisma();
const rotated = await rotateLegacyMoodLogSecrets({
findLegacy: () =>
p.user.findMany({
where: { moodLogWebhookSecret: { not: null } },
select: { id: true, moodLogWebhookSecret: true },
}),
rotate: async (id, encryptedSecret) => {
await p.user.update({
where: { id },
data: { moodLogWebhookSecret: encryptedSecret },
});
},
});
if (rotated > 0) {
workerLog(
"error",
`moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`,
);
}
} catch (err) {
workerLog("error", `moodlog-secret-migration failed: ${err}`);
}
}
/**
* v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the worker
* last shut down. Flips orphaned rows to `failed` so the operator can
+3 -3
View File
@@ -600,10 +600,10 @@ describe("get_integration_status", () => {
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([
{ integration: "moodlog" },
{ integration: "nightscout" },
] as never);
vi.mocked(getIntegrationStatus).mockResolvedValue({
integration: "moodlog",
integration: "nightscout",
state: "error_transient",
lastSuccessAt: new Date(
Date.now() - 55 * 24 * 60 * 60 * 1000,
@@ -620,7 +620,7 @@ describe("get_integration_status", () => {
providers: Array<Record<string, unknown>>;
};
expect(result.providers[0]).toMatchObject({
provider: "moodlog",
provider: "nightscout",
connected: true,
verdict: "stalled",
since: lastAttemptAt,
-4
View File
@@ -29,10 +29,6 @@ vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
const auditLog = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/auth/audit", () => ({
auditLog: (...args: unknown[]) => auditLog(...args),
+1 -1
View File
@@ -1484,7 +1484,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_integration_status",
title: "Get device & service sync status",
description:
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.",
"Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, Strava, Google Health) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getIntegrationStatusOutput,
+1 -14
View File
@@ -39,9 +39,8 @@ import {
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/moodlog";
import { getScoreForMood } from "@/lib/validations/mood";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
/** The MCP write provenance — distinct from MANUAL / TELEGRAM. */
const MCP_SOURCE = "MCP" as const;
@@ -619,18 +618,6 @@ export async function logMcpMood(input: {
);
}
// Reverse-sync to MoodLog (fire-and-forget; never throws).
void pushMoodEntriesToMoodLog(input.userId, [
{
date: entry.date,
moodLoggedAt,
mood: entry.mood,
note: savedNote,
tags: entry.tags,
source: MCP_SOURCE,
},
]).catch(() => {});
await auditLog("mcp.write.mood", {
userId: input.userId,
details: { source: MCP_SOURCE, idempotencyKey: input.idempotencyKey },
@@ -20,10 +20,6 @@ vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addMeta: vi.fn() }),
}));
+4 -17
View File
@@ -6,8 +6,8 @@
* the user from the linked chat instead. This helper holds the shared
* write so the two entry points agree on the canonical shape: the same
* `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz`
* anchoring, the same cache invalidation + DAY-rollup recompute + MoodLog
* reverse-sync push the route fires. `source` is pinned to "TELEGRAM"
* anchoring, the same cache invalidation + DAY-rollup recompute the route
* fires. `source` is pinned to "TELEGRAM"
* (already an accepted free-text source value, no enum migration needed).
*
* The `userId` is NEVER taken from the Telegram payload — the caller
@@ -16,12 +16,11 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/moodlog";
import { getScoreForMood } from "@/lib/validations/mood";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { getEvent } from "@/lib/logging/context";
export interface TelegramMoodResult {
@@ -109,18 +108,6 @@ export async function logTelegramMood(input: {
);
}
// Reverse-sync to MoodLog (fire-and-forget; never throws).
void pushMoodEntriesToMoodLog(input.userId, [
{
date: entry.date,
moodLoggedAt,
mood: entry.mood,
note: readNote(entry.noteEncrypted, entry.note),
tags: entry.tags,
source: "TELEGRAM",
},
]).catch(() => {});
return { created: true, moodEntryId: entry.id, score };
}
-84
View File
@@ -1,84 +0,0 @@
/**
* moodLog webhook secret — encrypt-at-rest helpers (V3 audit STILL-V2-C-2).
*
* Previously the `mood_log_webhook_secret` column stored the secret in
* plaintext. The webhook handler did a timing-safe `Buffer.equals` against
* every enabled user's plaintext secret to find a match.
*
* The new contract:
* - All writes go through `encryptMoodLogSecret()` → AES-256-GCM
* base64 envelope.
* - Reads go through `readMoodLogSecret()` which decrypts envelopes;
* if a stored value is NOT a valid envelope it is treated as a legacy
* plaintext secret (transitional grace period). Operators can leave
* existing rows in place — the next write rotates them to encrypted.
*
* The webhook lookup still iterates candidates, but each candidate is
* decrypted before the timing-safe compare. Cost is O(n) decrypt calls
* per webhook invocation; acceptable for small deployments and easily
* upgraded later by adding an HMAC lookup column.
*/
import { encrypt, decrypt, extractKeyId } from "@/lib/crypto";
export function encryptMoodLogSecret(plaintext: string): string {
return encrypt(plaintext);
}
export function readMoodLogSecret(stored: string | null): string | null {
if (!stored) return null;
// Anything carrying a `<keyId>.<payload>` envelope must decrypt
// cleanly. A failure here means either the active key map no longer
// includes the version this row was written under (rotation playbook
// not finished) or the ciphertext was tampered with directly in the
// column. Returning the raw envelope bytes as if they were the
// plaintext secret would silently turn a corrupted row into an
// attacker-chosen webhook secret — so surface the failure instead.
if (extractKeyId(stored) !== null) {
return decrypt(stored);
}
// Legacy plaintext path — unprefixed values predate the encrypt-at-
// rest contract. Return as-is so the integration keeps working until
// the next write rotates them; `rotateLegacyMoodLogSecrets()` runs
// at boot to convert them.
return stored;
}
/**
* Whether the stored value still looks like an unencrypted legacy secret.
* Used by the boot-time migration helper + the rotation flow.
*/
export function isLegacyPlaintext(stored: string | null): boolean {
if (!stored) return false;
// A row is legacy plaintext only when it does NOT carry the
// `<keyId>.<payload>` envelope. Envelope-shaped rows are encrypted —
// even ones we currently can't decrypt (which should fail loud, not
// get re-rotated as if they were plaintext).
return extractKeyId(stored) === null;
}
interface MoodLogSecretMigrationDeps {
findLegacy: () => Promise<
Array<{ id: string; moodLogWebhookSecret: string | null }>
>;
rotate: (id: string, encryptedSecret: string) => Promise<void>;
}
/**
* One-shot startup migration: encrypts any leftover plaintext secret rows
* with AES-GCM. Idempotent — encrypted rows are skipped automatically.
* Returns the number of rows rotated.
*/
export async function rotateLegacyMoodLogSecrets(
deps: MoodLogSecretMigrationDeps,
): Promise<number> {
const candidates = await deps.findLegacy();
let rotated = 0;
for (const row of candidates) {
if (!row.moodLogWebhookSecret) continue;
if (!isLegacyPlaintext(row.moodLogWebhookSecret)) continue;
const encrypted = encryptMoodLogSecret(row.moodLogWebhookSecret);
await deps.rotate(row.id, encrypted);
rotated += 1;
}
return rotated;
}
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.12.0 — moodLog is deprecated (superseded by native mood entries +
* structured tags + rated factors), but kept FUNCTIONAL this release. This
* test pins the `@deprecated` JSDoc marker on the surface so a future edit
* that touches these files can't silently drop the deprecation notice
* before the planned removal.
*/
const ROOT = join(__dirname, "..", "..", "..", "..");
const DEPRECATED_FILES = [
"src/app/api/integrations/moodlog/webhook/route.ts",
"src/lib/moodlog/push.ts",
"src/lib/moodlog/sync.ts",
"src/lib/validations/moodlog.ts",
];
describe("moodLog deprecation markers", () => {
for (const rel of DEPRECATED_FILES) {
it(`marks ${rel} with @deprecated`, () => {
const src = readFileSync(join(ROOT, rel), "utf8");
expect(src).toContain("@deprecated");
// The marker must mention the superseding native path so the note is
// actionable, not a bare tag.
expect(src.toLowerCase()).toContain("superseded");
});
}
});
@@ -1,64 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
upsert: vi.fn(),
updateMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import { persistMoodLogSourceEntry } from "../persistence";
describe("persistMoodLogSourceEntry concurrent legacy adoption", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("applies the losing replay payload to the row adopted by the winner", async () => {
const moodLoggedAt = new Date("2026-07-19T08:30:00.000Z");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(
Object.assign(new Error("natural-key collision"), { code: "P2002" }),
);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValueOnce({
count: 0,
});
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValueOnce({
date: "2026-07-19",
moodLoggedAt,
} as never);
vi.mocked(prisma.moodEntry.update).mockResolvedValueOnce({} as never);
await persistMoodLogSourceEntry("user-1", {
externalId: "provider-event-1",
date: "2026-07-19",
moodLoggedAt,
mood: "SUPER_GUT",
score: 5,
tags: ["updated"],
});
expect(prisma.moodEntry.update).toHaveBeenCalledWith({
where: {
userId_source_externalId: {
userId: "user-1",
source: "MOODLOG",
externalId: "provider-event-1",
},
},
data: {
mood: "SUPER_GUT",
score: 5,
tags: JSON.stringify(["updated"]),
source: "MOODLOG",
deletedAt: null,
date: "2026-07-19",
moodLoggedAt,
},
});
});
});
-186
View File
@@ -1,186 +0,0 @@
/**
* v1.4.50 — reverse-sync push helper.
*
* The push side mirrors the pull (`sync.ts`) tier by tier: stored
* credentials → SSRF guard → bounded fetch → typed response folding.
* The tests pin the contract the iOS + web mood-entry create handlers
* rely on, so a future refactor of the helper can't silently break
* the fire-and-forget call sites that no longer await the result.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (v: string) => v.replace(/^enc:/, ""),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: (u: string) => /^https?:\/\/(?!127\.|localhost|10\.)/.test(u),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
}),
}));
import { pushMoodEntriesToMoodLog } from "../push";
import { prisma } from "@/lib/db";
const USER_OK = {
moodLogEnabled: true,
moodLogUrlEncrypted: "enc:https://moodlog.example.com",
moodLogApiKeyEncrypted: "enc:secret-key-123",
};
const ENTRY = {
date: "2026-05-24",
moodLoggedAt: new Date("2026-05-24T08:30:00Z"),
mood: "GUT",
note: "Felt productive after my run.",
tags: JSON.stringify(["exercise", "morning"]),
source: "MANUAL",
};
describe("pushMoodEntriesToMoodLog", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ imported: 1, updated: 0, failed: 0 }), {
status: 200,
}),
);
globalThis.fetch = fetchMock as never;
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER_OK as never);
});
it("skips entirely when no entries pass the MOODLOG filter", async () => {
const result = await pushMoodEntriesToMoodLog("user-1", [
{ ...ENTRY, source: "MOODLOG" },
]);
expect(result).toEqual({ pushed: 0, skipped: 1, status: "skipped" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when the user has no moodLog credentials stored", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodLogEnabled: false,
moodLogUrlEncrypted: null,
moodLogApiKeyEncrypted: null,
} as never);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("skipped");
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses to push when the stored URL points at a non-public host", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...USER_OK,
moodLogUrlEncrypted: "enc:http://127.0.0.1:3000",
} as never);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(fetchMock).not.toHaveBeenCalled();
});
it("posts the entry to /api/integrations/health-log/mood with HEALTHLOG source", async () => {
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("ok");
expect(result.pushed).toBe(1);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/api/integrations/health-log/mood");
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer secret-key-123",
);
const body = JSON.parse(init.body as string) as {
entries: Array<{
source: string;
time: string;
date: string;
mood: string;
tags?: string[];
}>;
};
expect(body.entries).toHaveLength(1);
expect(body.entries[0].source).toBe("HEALTHLOG");
expect(body.entries[0].date).toBe("2026-05-24");
expect(body.entries[0].time).toBe("2026-05-24T08:30:00.000Z");
expect(body.entries[0].mood).toBe("GUT");
// Tags are parsed from the JSON-string column into an array of
// key strings the MoodLog endpoint accepts directly.
expect(body.entries[0].tags).toEqual(["exercise", "morning"]);
});
it("returns ok with pushed=0 when MoodLog responds 200 but no rows were accepted", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ imported: 0, updated: 0, failed: 1 }), {
status: 200,
}),
);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("ok");
expect(result.pushed).toBe(0);
expect(result.skipped).toBe(1);
});
it("reports failed on a 5xx without throwing", async () => {
fetchMock.mockResolvedValueOnce(
new Response("upstream down", { status: 502 }),
);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(result.pushed).toBe(0);
});
it("reports failed on a network blip (fetch reject) without throwing", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(result.pushed).toBe(0);
});
it("refuses to follow a 3xx redirect (SSRF defence)", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 302 }));
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
});
it("omits the tags field when the column is null or malformed JSON", async () => {
await pushMoodEntriesToMoodLog("user-1", [{ ...ENTRY, tags: null }]);
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as { entries: Array<{ tags?: unknown }> };
expect("tags" in body.entries[0]).toBe(false);
fetchMock.mockClear();
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ imported: 1 }), { status: 200 }),
);
await pushMoodEntriesToMoodLog("user-1", [
{ ...ENTRY, tags: "not-valid-json{{" },
]);
const body2 = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as { entries: Array<{ tags?: unknown }> };
expect("tags" in body2.entries[0]).toBe(false);
});
});
-98
View File
@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
moodEntry: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: vi.fn(() => true),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { prisma } from "@/lib/db";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { safeFetch } from "@/lib/safe-fetch";
import { syncMoodLogEntries } from "../sync";
const SOURCE_ENTRY = {
id: "provider-event-1",
date: "2026-07-19",
time: "2026-07-19T08:30:00.000Z",
mood: "GUT",
score: 4,
tags: ["walk"],
};
describe("syncMoodLogEntries persistence completion", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodLogEnabled: true,
moodLogUrlEncrypted: "https://moodlog.example.test",
moodLogApiKeyEncrypted: "api-key",
moodLogLastSyncedAt: new Date("2026-07-18T08:30:00.000Z"),
} as never);
vi.mocked(isReauthRequired).mockResolvedValue(false);
vi.mocked(safeFetch).mockResolvedValue(
new Response(JSON.stringify({ version: "1", entries: [SOURCE_ENTRY] }), {
status: 200,
}),
);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
it("holds the watermark and success marker when a source upsert fails", async () => {
const persistenceError = new Error("database unavailable");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(persistenceError);
await expect(syncMoodLogEntries("user-1")).rejects.toThrow(
"database unavailable",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith({
userId: "user-1",
integration: "moodlog",
kind: "transient",
message: "database unavailable",
errorCode: "persistence_failed",
});
});
});
-132
View File
@@ -1,132 +0,0 @@
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
export interface MoodLogSourceEntry {
externalId: string | null;
date: string;
moodLoggedAt: Date;
mood: string;
score: number;
tags?: readonly string[];
}
/**
* Persist one moodLog-owned source row using the provider id when available
* and the legacy wall-clock key otherwise.
*
* A provider may add an id after an older delivery already created the row by
* wall-clock key. In that case the external-id upsert collides with the legacy
* unique constraint; adopt that durable row and attach the provider id rather
* than creating a duplicate or retrying forever.
*/
export async function persistMoodLogSourceEntry(
userId: string,
entry: MoodLogSourceEntry,
): Promise<void> {
const tags = entry.tags ? JSON.stringify(entry.tags) : null;
const update = {
mood: entry.mood,
score: entry.score,
tags,
source: "MOODLOG",
deletedAt: null,
} as const;
if (!entry.externalId) {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
},
update,
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
...update,
},
});
return;
}
let collisionError: unknown;
try {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
update: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
externalId: entry.externalId,
...update,
},
});
return;
} catch (err) {
if (!isP2002(err)) throw err;
collisionError = err;
}
const adopted = await prisma.moodEntry.updateMany({
where: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
source: "MOODLOG",
externalId: null,
},
data: {
...update,
externalId: entry.externalId,
},
});
if (adopted.count === 1) return;
// A concurrent replay may have adopted the same legacy row first.
const winner = await prisma.moodEntry.findUnique({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
select: { date: true, moodLoggedAt: true },
});
if (
winner?.date === entry.date &&
winner.moodLoggedAt.getTime() === entry.moodLoggedAt.getTime()
) {
await prisma.moodEntry.update({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
data: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
});
return;
}
throw collisionError;
}
-224
View File
@@ -1,224 +0,0 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
/**
* v1.4.50 — reverse-sync push: HealthLog → MoodLog.
*
* The historic sync direction (`syncMoodLogEntries` in `sync.ts`)
* polls MoodLog every 15 minutes and imports new rows. That's a
* one-way bridge: anything the user logs INSIDE HealthLog (the iOS
* app's mood reminder, the web form, a Telegram nudge handler) never
* reaches MoodLog. The maintainer's report: they tap a mood in iOS, the entry
* lands in HealthLog, but never appears in MoodLog's timeline.
*
* This helper closes that gap. Every successful create on
* `POST /api/mood-entries` and `POST /api/mood-entries/bulk` triggers
* a fire-and-forget push to MoodLog's `POST /api/integrations/health-
* log/mood` (added in MoodLog's matching release). The push:
*
* - Skips rows whose `source` is already `MOODLOG` — those came
* FROM MoodLog via the pull side, pushing them back would echo.
* - Uses the same per-user `moodLogUrlEncrypted` + `moodLogApiKey
* Encrypted` credentials the pull side reads, plus the same
* `isPublicUrl` SSRF guard.
* - Carries `mood`, `note`, `tags` (as key strings), and the
* original `moodLoggedAt` in the body's `time` field so MoodLog's
* upsert dedup key `(userId, date, createdAt)` stays consistent
* across both directions.
* - Tags the inbound entries with `source: "HEALTHLOG"` on the
* MoodLog side so the next pull cycle's `syncMoodLogEntries` can
* filter them out (avoiding an echo-loop where the same row
* bounces back as a MOODLOG-tagged entry).
*
* Failure mode: this is a best-effort push, intentionally NOT awaited
* by the create handler. Network errors, MoodLog being down, or
* MoodLog rejecting the payload all emit a wide-event warning but
* never bubble back to the user's create request. The next entry the
* user logs retries the push; if MoodLog has been down for hours, the
* user can hit "Force sync" in settings to backfill (TBD — for v1.5).
*/
export interface MoodEntryForPush {
/** YYYY-MM-DD, anchored to the user's display timezone. */
date: string;
/** ISO timestamp — `moodLoggedAt` from the HealthLog row. */
moodLoggedAt: Date;
mood: string;
note: string | null;
/** JSON-encoded string or null — pull side normalises to key array. */
tags: string | null;
/** v1.4.50 — only `MANUAL` / `WEB` / `TELEGRAM` / `iOS` push to MoodLog. */
source: string;
}
/**
* @deprecated The moodLog integration is superseded by native mood entries
* plus structured tags and rated factors. Mood lives fully inside HealthLog,
* so the reverse-sync bridge no longer adds value. Kept functional for
* existing setups; slated for removal in a future major release.
*/
export async function pushMoodEntriesToMoodLog(
userId: string,
entries: ReadonlyArray<MoodEntryForPush>,
): Promise<{
pushed: number;
skipped: number;
status: "ok" | "skipped" | "failed";
}> {
// Entries originating in MoodLog mustn't loop back. The pull side
// also filters incoming `HEALTHLOG`-tagged rows; this is the
// outbound mirror of that gate.
const candidates = entries.filter((e) => e.source !== "MOODLOG");
if (candidates.length === 0) {
return { pushed: 0, skipped: entries.length, status: "skipped" };
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
},
});
if (
!user?.moodLogEnabled ||
!user.moodLogUrlEncrypted ||
!user.moodLogApiKeyEncrypted
) {
return {
pushed: 0,
skipped: candidates.length,
status: "skipped",
};
}
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
if (!isPublicUrl(baseUrl)) {
// Same SSRF guard pull side uses — a stored row pointing at an
// internal target must never receive the user's apiKey.
getEvent()?.addWarning(
`moodlog push refused for ${userId}: stored URL points at non-public host`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
const url = new URL("/api/integrations/health-log/mood", baseUrl);
const body = {
entries: candidates.map((e) => ({
date: e.date,
time: e.moodLoggedAt.toISOString(),
mood: e.mood,
note: e.note,
tags: parseTagKeys(e.tags),
source: "HEALTHLOG" as const,
})),
};
const start = performance.now();
let response: Response;
try {
// requirePublicHost adds the DNS-rebinding pin to the input-time
// isPublicUrl guard (issue #217). The reverse-sync path attaches a
// user-supplied bearer; a rebinding flip would leak it on the
// connect call to the swapped private address.
response = await safeFetch(
url.toString(),
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
{ timeoutMs: 10_000, requirePublicHost: true },
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addExternalCall({
service: "moodlog",
method: "pushMoodEntriesToMoodLog",
duration_ms: Math.round(performance.now() - start),
});
getEvent()?.addWarning(
`moodlog push failed for ${userId} (fetch): ${message}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
getEvent()?.addExternalCall({
service: "moodlog",
method: "pushMoodEntriesToMoodLog",
duration_ms: Math.round(performance.now() - start),
status: response.status,
});
// 3xx — manual redirect mode surfaces them. Match the pull-side
// behaviour: a redirect from a public host that ended up pointing
// at an RFC1918 target would otherwise leak the apiKey.
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodlog push refused redirect for ${userId}: HTTP ${response.status}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
if (!response.ok) {
getEvent()?.addWarning(
`moodlog push HTTP ${response.status} for ${userId}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
// Surface MoodLog's per-entry receipt counts so the dashboard
// admin diagnostic + Wide Event timeline can attribute partial
// failures correctly (e.g. an unknown tag key dropped on the
// MoodLog side reports `failed > 0` while imported/updated > 0).
type PushResult = {
imported?: number;
updated?: number;
failed?: number;
};
let result: PushResult | null = null;
try {
result = (await response.json()) as PushResult;
} catch {
// MoodLog should always return JSON; tolerate the parse failure
// so a stale upstream doesn't undo the successful HTTP write.
}
const imported = result?.imported ?? 0;
const updated = result?.updated ?? 0;
const failed = result?.failed ?? 0;
return {
pushed: imported + updated,
skipped: entries.length - candidates.length + failed,
status: "ok",
};
}
/**
* Parse the JSON-encoded `tags` column into an array of key strings.
* Returns an empty array on null / parse failure so the push body
* always carries a well-formed `tags: string[]` (or omitted entirely).
*/
function parseTagKeys(raw: string | null | undefined): string[] | undefined {
if (!raw) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const keys = parsed
.filter((v): v is string => typeof v === "string" && v.length > 0)
.slice(0, 50);
return keys.length > 0 ? keys : undefined;
} catch {
return undefined;
}
}
-296
View File
@@ -1,296 +0,0 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
* Sync mood entries from a user's moodLog instance.
* Fetches from the health-log export endpoint and upserts into local DB.
*
* @deprecated The moodLog integration is superseded by native mood entries
* plus structured tags and rated factors. Mood is tracked fully inside
* HealthLog now, so the external pull bridge no longer adds value. Kept
* functional for existing setups; slated for removal in a future major
* release.
*/
export async function syncMoodLogEntries(
userId: string,
opts?: { fullSync?: boolean },
): Promise<number> {
// 1. Read user credentials
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
});
if (
!user?.moodLogEnabled ||
!user.moodLogUrlEncrypted ||
!user.moodLogApiKeyEncrypted
) {
return 0;
}
// Park: if we already know the apiKey is rejected, skip the network
// round-trip until the user re-saves credentials. The settings PUT
// route clears this state (see /api/settings/moodlog).
if (await isReauthRequired(userId, "moodlog")) {
getEvent()?.addWarning(
`moodlog sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
}
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
// SSRF guard at the actual fetch site. The credential write path
// is also guarded (moodLogCredentialsSchema), but a row stored
// before that guard landed could still point at an internal IP.
// Re-checking here means the sync worker refuses internal targets
// even on legacy data, and the user's apiKey is never sent there.
if (!isPublicUrl(baseUrl)) {
getEvent()?.addWarning(
`moodLog sync refused for user ${userId}: stored URL points at non-public host`,
);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "reauth_required",
message: "Stored moodLog URL points at a non-public host",
errorCode: "ssrf_refused",
});
return 0;
}
// 2. Determine date range
const now = new Date();
const to = now.toISOString().slice(0, 10); // YYYY-MM-DD
let from: string;
if (opts?.fullSync || !user.moodLogLastSyncedAt) {
// Full sync: fetch all available data
from = "2000-01-01";
} else {
// Incremental: from lastSyncedAt - 1 hour overlap
const d = new Date(user.moodLogLastSyncedAt.getTime() - 3600 * 1000);
from = d.toISOString().slice(0, 10);
}
// 3. Fetch from moodLog
const url = new URL("/api/integrations/health-log/mood", baseUrl);
url.searchParams.set("from", from);
url.searchParams.set("to", to);
// safeFetch keeps the no-redirect-follow guard (the apiKey would
// otherwise leak on an attacker-controlled 302 hop) and adds a hard
// upper bound via AbortSignal.timeout. requirePublicHost routes the
// dial through the pinned-IP dispatcher so a DNS rebinding cannot
// flip a public hostname to 169.254.169.254 / RFC1918 between the
// input-time accept and the connect call.
const timeoutMs = opts?.fullSync ? 60_000 : 15_000;
let response: Response;
const fetchStart = performance.now();
try {
response = await safeFetch(
url.toString(),
{ headers: { Authorization: `Bearer ${apiKey}` } },
{ timeoutMs, requirePublicHost: true },
);
} catch (err) {
getEvent()?.addExternalCall({
service: "moodlog",
method: "syncMoodLogEntries",
duration_ms: Math.round(performance.now() - fetchStart),
});
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message,
errorCode: "fetch_failed",
});
return 0;
}
getEvent()?.addExternalCall({
service: "moodlog",
method: "syncMoodLogEntries",
duration_ms: Math.round(performance.now() - fetchStart),
status: response.status,
});
// Treat any 3xx as failure (manual redirect mode surfaces them).
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`,
);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message: `moodLog sync refused redirect (HTTP ${response.status})`,
errorCode: `http_${response.status}`,
});
return 0;
}
if (!response.ok) {
getEvent()?.addWarning(
`Sync failed for user ${userId}: HTTP ${response.status}`,
);
// 401/403 — apiKey rejected. Park the integration so we don't
// hammer the upstream until the user re-saves credentials.
const isAuthFailure = response.status === 401 || response.status === 403;
await recordSyncFailure({
userId,
integration: "moodlog",
kind: isAuthFailure ? "reauth_required" : "transient",
message: `moodLog sync HTTP ${response.status}`,
errorCode: `http_${response.status}`,
});
return 0;
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message: `moodLog sync invalid JSON: ${message}`,
errorCode: "invalid_json",
});
return 0;
}
// Validate response has entries array
if (
!data ||
typeof data !== "object" ||
!("entries" in data) ||
!Array.isArray((data as { entries?: unknown }).entries)
) {
getEvent()?.addWarning(`Invalid response format for user ${userId}`);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message: "moodLog sync invalid response format (missing 'entries' array)",
errorCode: "invalid_format",
});
return 0;
}
const entries = (data as { entries: unknown[] }).entries;
// 4. Upsert entries (note field is intentionally NOT imported)
//
// v1.4.50 — entries MoodLog re-exports with `loggedVia: "HEALTHLOG"`
// are echoes of rows HealthLog pushed via the reverse-sync POST.
// Re-importing them would flip the `source` column from the original
// attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG, losing the
// provenance the user already established and double-counting the
// entry in any source-segregated dashboard. Skip them on the pull
// side so the loop closes at one round-trip.
let imported = 0;
let echoSkipped = 0;
for (const e of entries) {
const entry = e as {
id?: string;
time: string;
date: string;
mood: string;
score: number;
tags?: string[];
loggedVia?: string;
};
if (entry.loggedVia === "HEALTHLOG") {
echoSkipped += 1;
continue;
}
try {
const moodLoggedAt = new Date(entry.time);
const date = entry.date;
// v1.12.1 — when the upstream supplies a stable id, upsert on
// `(userId, source, externalId)` so a re-pull with a re-rounded /
// re-zoned `time` is idempotent; otherwise the legacy wall-clock
// key. The id is bounded to the column size to match the webhook.
const externalId =
typeof entry.id === "string" && entry.id.length > 0
? entry.id.slice(0, 120)
: null;
await persistMoodLogSourceEntry(userId, {
externalId,
date,
moodLoggedAt,
mood: entry.mood,
score: entry.score,
tags: entry.tags,
});
imported++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`Failed to upsert moodLog entry: ${message}`);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message,
errorCode: "persistence_failed",
});
throw err;
}
}
// 5. Update lastSyncedAt + clear sync-status streak
await prisma.user.update({
where: { id: userId },
data: { moodLogLastSyncedAt: now },
});
await recordSyncSuccess(userId, "moodlog");
// v1.4.50 — annotate the echo-skip count so an operator can see how
// many reverse-sync entries the pull side filtered out per cycle.
// Should match the user's recent `pushMoodEntriesToMoodLog` count;
// a divergent number signals either a stale MoodLog deploy
// (pre-HEALTHLOG-source) or a configuration drift.
if (echoSkipped > 0) {
getEvent()?.addExternalCall({
service: "moodlog",
method: "syncMoodLogEntries.echoSkipped",
duration_ms: 0,
});
}
// v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The
// sync upserts a batch spanning many days; one bounded
// recompute is cheaper than firing N per-row hooks. Best-effort:
// a rollup failure here must not bubble up and undo the
// recordSyncSuccess we just committed.
if (imported > 0) {
try {
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
} catch (err) {
getEvent()?.addWarning(`Mood rollup recompute failed after sync: ${err}`);
}
}
return imported;
}
+1 -1
View File
@@ -23,7 +23,7 @@ import {
hideCatalogueTagSchema,
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/moodlog";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
import {
dataEnvelope,
errorEnvelope,
+1 -2
View File
@@ -1,6 +1,6 @@
/**
* Query keys — external integrations: API tokens, Telegram, Withings,
* WHOOP, Fitbit, moodLog, and the Apple Health export import job.
* WHOOP, Fitbit, and the Apple Health export import job.
* Part of the centralized factory; aggregated in `./index.ts`.
*/
export const integrationKeys = {
@@ -63,7 +63,6 @@ export const integrationKeys = {
strava: () => ["strava"] as const,
stravaStatus: () => ["strava", "status"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,
// Apple Health live-sync freshness comes from the existing HealthKit config
// endpoint. It is intentionally separate from the OAuth integration ledger:
+2 -2
View File
@@ -113,7 +113,7 @@ export function buildMoodTargets({
? { category: "Moderate", color: "var(--dracula-yellow)" }
: { category: "Low", color: "var(--destructive)" }
: null,
source: "moodLog",
source: "Mood entries",
...consistency,
},
];
@@ -137,7 +137,7 @@ export function buildMoodTargets({
: standardDeviation <= 1
? { category: "Stable", color: "var(--dracula-yellow)" }
: { category: "Fluctuating", color: "var(--destructive)" },
source: "moodLog",
source: "Mood entries",
...consistency,
});
}
+2 -2
View File
@@ -10,7 +10,7 @@
*
* We accept three shapes:
* - `null` (and omitted/undefined): "no tags on this entry"
* - empty string `""`: legacy moodLog wire format (handled as null)
* - empty string `""`: legacy wire format (handled as null)
* - a JSON-string that parses to an array of strings.
*
* Anything else (object, JSON-with-numbers, garbage) is rejected at
@@ -51,7 +51,7 @@ describe("moodEntrySchema.tags strict validation", () => {
expect(r.success).toBe(true);
});
it("accepts an empty string (legacy moodLog wire format)", () => {
it("accepts an empty string (legacy wire format)", () => {
const r = parseWithMoodTags("");
expect(r.success).toBe(true);
});
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
moodLogCredentialsSchema,
createMoodEntrySchema,
updateMoodEntrySchema,
} from "../moodlog";
import { createMoodEntrySchema, updateMoodEntrySchema } from "../mood";
describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" };
@@ -71,44 +67,3 @@ describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
).toBe(false);
});
});
describe("moodLogCredentialsSchema SSRF guard", () => {
it("accepts a public moodLog URL", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "https://moodlog.app",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(true);
});
it("rejects RFC1918 URLs", () => {
for (const url of [
"http://10.0.0.1",
"http://192.168.1.1",
"http://172.16.0.1",
"http://127.0.0.1",
]) {
const r = moodLogCredentialsSchema.safeParse({
url,
apiKey: "k".repeat(40),
});
expect(r.success, `expected reject for ${url}`).toBe(false);
}
});
it("rejects link-local (cloud metadata) URLs", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
it("rejects localhost", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://localhost:8080",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
});
-1
View File
@@ -66,7 +66,6 @@ export const adminSettingsSchema = z
glitchtipEnvironment: z.string().optional(),
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),
// Document vault — the two admin-tunable limits. The per-file cap is
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
// in-memory reads are load-bearing past that; the runtime resolver
-1
View File
@@ -320,7 +320,6 @@ const appSettingsBackupSchema = z
webPushVapidPrivateKeyEncrypted: z.string().nullable(),
webPushVapidSubject: z.string().nullable(),
apiGlobal: z.boolean(),
moodLogGlobal: z.boolean(),
umamiEnabled: z.boolean(),
umamiScriptUrl: z.string().nullable(),
umamiWebsiteId: z.string().nullable(),
@@ -1,70 +1,13 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* @deprecated The standalone moodLog integration is superseded by native
* mood entries plus structured tags and rated factors — mood is tracked
* fully inside HealthLog now. These schemas remain only to keep the
* existing webhook + sync paths functional; the surface is slated for
* removal in a future major release. The native mood schemas
* (`createMoodEntrySchema` etc.) below are the supported path.
* Mood-entry validation schemas.
*
* v1.32.33 — the file previously also carried the credential, webhook and
* pull-response schemas of the retired moodLog bridge. Mood is tracked
* natively, so only the entry schemas remain and the module is named for
* what it validates.
*/
export const moodLogCredentialsSchema = z.object({
url: z
.string()
.url()
.max(500)
// SSRF guard: stored URL must point at a public host. The sync
// worker fetches from this URL with the user's apiKey in the
// Authorization header, so a stored RFC1918 / link-local target
// would let any user pull cloud-metadata or local-network data
// back through their account.
.refine((u) => isPublicUrl(u), {
message: "URL must point at a public host (no RFC1918 / link-local)",
}),
apiKey: z.string().min(1).max(200),
});
export const moodLogWebhookPayloadSchema = z.object({
event: z.enum(["mood.created", "mood.updated", "mood.deleted"]),
timestamp: z.string().datetime(),
entry: z.object({
// v1.12.1 — optional source-stable entry id. When present, the
// webhook dedups on `(userId, source, externalId)` so a re-emit with
// a re-rounded / re-zoned `time` is idempotent instead of minting a
// second row. Absent → the legacy `(userId, date, moodLoggedAt)`
// path. Bounded so a malformed upstream id can't bloat the column.
id: z.string().min(1).max(120).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().datetime(),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.array(z.string()).max(50).optional(),
loggedVia: z.enum(["WEB", "TELEGRAM", "DAYLIO"]).optional(),
}),
});
export const moodLogSyncResponseSchema = z.object({
version: z.string(),
entries: z.array(
z.object({
// v1.12.1 — optional source-stable id, mirrors the webhook entry.
// Carried into `externalId` for idempotent re-import.
id: z.string().min(1).max(120).optional(),
date: z.string(),
time: z.string(),
mood: z.string(),
score: z.number(),
tags: z.array(z.string()).optional(),
loggedVia: z.string().optional(),
}),
),
});
export type MoodLogCredentials = z.infer<typeof moodLogCredentialsSchema>;
export type MoodLogWebhookPayload = z.infer<typeof moodLogWebhookPayloadSchema>;
export type MoodLogSyncResponse = z.infer<typeof moodLogSyncResponseSchema>;
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
// --- CRUD schemas for mood entries ---
@@ -76,6 +19,12 @@ export const moodLevelEnum = z.enum([
"LAUSIG",
]);
/**
* Provenance of a stored mood entry. `MOODLOG` is a legacy value: the bridge
* that wrote it was removed in v1.32.33, but rows it created are the user's
* own history and keep their label, so the list filter and the source badge
* must keep resolving it. Nothing mints new `MOODLOG` rows.
*/
export const moodSourceEnum = z.enum([
"MANUAL",
"MOODLOG",