mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge remote-tracking branch 'origin/main' into ready/pr-1131
This commit is contained in:
commit
fbd639d9f1
87 files changed
+5627
-772
No files matched your search
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## [1.40.0] — 2026-10-04
|
||||
|
||||
Back up the encryption key and refuse a key that cannot open the data;
|
||||
several courses and custom categories for medications; custom symptoms;
|
||||
document reads in the background; sick notes; lab unit refusal and charts
|
||||
that follow the source priority; blood pressure no longer loses its
|
||||
diastolic value; document previews work again.
|
||||
|
||||
### Added
|
||||
|
||||
- **Encryption key backup step (migration 0365).** Admin step with key id,
|
||||
fingerprint and per-platform guidance (`HEALTHLOG_PLATFORM`), an optional
|
||||
"check my copy" that compares and discards the pasted key, a reminder that
|
||||
returns when the key changes.
|
||||
- **Boot key check.** A canary per key id, probed oldest values first across
|
||||
string and Bytes columns; a proven mismatch answers 503
|
||||
`encryption.key_mismatch` everywhere except `/api/health` and
|
||||
`/api/version`, MCP included, and starts no worker; an unproven result
|
||||
serves, warns and re-checks. `ENCRYPTION_KEY_CHECK=warn` as a last resort.
|
||||
- **Medication courses and custom categories (#1024, #1041, migration
|
||||
0368).** Several courses per medication; compliance, streak, cadence and
|
||||
dose history count every course and never the gaps; existing dated
|
||||
medications become one course each. Own categories as `custom:<id>` with
|
||||
`categoryLabel`; the runtime category table is now in the schema.
|
||||
- **Custom symptoms (#981, migration 0369).** Up to eight definitions, events
|
||||
with a 0 to 10 intensity, a capture kind, one correlation channel per
|
||||
symptom, a Coach block; gated on the Illness module.
|
||||
- **Document AI as background runs (#1090, migrations 0366 and 0370).**
|
||||
Index, summary, suggestions and extraction accept `Prefer: respond-async`;
|
||||
the lab scan always runs in the background; `GET /api/ai-runs/{id}`.
|
||||
- **`SICK_NOTE` document kind.**
|
||||
- **Natural durations (#1101)** in the sleep field.
|
||||
- **Reasoning effort per provider (#1126).** The Local and OpenAI-compatible
|
||||
forms gain a Reasoning select (Default, Off, Low, Medium, High), stored on
|
||||
the provider's chain entry; Default sends nothing, Off sends
|
||||
`reasoning_effort: "none"`. A rejection of that field no longer switches
|
||||
the endpoint's JSON mode off.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blood pressure saved without its diastolic value (iOS #15, migration
|
||||
0371).** The iPhone app sends the two halves of a manual reading as two
|
||||
requests under one `Idempotency-Key`, and the server answered the second
|
||||
with the first one's cached response. The replay cache now stores a SHA-256
|
||||
fingerprint of method, path and body (JSON canonicalised, a multipart
|
||||
boundary normalised) and replays only the same request; a different body
|
||||
under the same key is saved. Readings saved before this stay incomplete;
|
||||
`docs/ops/blood-pressure-missing-diastolic.md` lists them.
|
||||
- **Document previews and scanned PDFs (#1124).** The image hoisted an older
|
||||
`@napi-rs/canvas` binary next to the pinned loader, so every thumbnail
|
||||
failed; the build now hoists the pinned version and fails on a mismatch.
|
||||
PDFs render with pdfjs font, cmap and wasm data and a registered fallback
|
||||
font instead of absent system fonts, and an all-blank render reports
|
||||
`raster-failed`. The thumbnail job logs why a preview is missing.
|
||||
- **Portable backups.** Cycle free text, custom mood labels, visit notes
|
||||
and vaccination notes are carried readable and re-encrypted on restore
|
||||
instead of as the source server's ciphertext; custom mood tags restore
|
||||
without mood entries; a round-trip guard fails on any ciphertext in a
|
||||
portable file. A medication update and its course share one transaction.
|
||||
- **Lab unit mismatch (#1094).** A reading in another unit than its marker is
|
||||
refused (`labs.unit.mismatch`) on every write path; a marker's unit is
|
||||
locked while it has readings (`biomarkers.unit.locked`).
|
||||
- **Source priority in series (#1108).** Pulse, HRV and SpO2 series read the
|
||||
ladder's source per day; unranked types keep every source.
|
||||
- **Sign-in over plain http (#1097).** Refused with
|
||||
`auth.session.insecure_transport` before a password, code or challenge is
|
||||
used; the sign-in page says so first.
|
||||
- **Chart window (#1102).** Ends at the end of today in the profile time zone.
|
||||
- **Workouts.** The insight cap survives a delete (migration 0367); a changed
|
||||
`manual:` resubmit updates the workout and its paragraph.
|
||||
- **Display.** Clinician decimals in the owner's locale, stated dates east of
|
||||
UTC+12, archive month grouping, lab list columns without a range, icon
|
||||
picker touch targets and labels, symptom entry load errors.
|
||||
|
||||
### Changed
|
||||
|
||||
- Quick-entry forms load on demand; the four eager route baselines are
|
||||
lowered to the new measurements.
|
||||
|
||||
## [1.39.9] — 2026-10-03
|
||||
|
||||
Workouts can be logged by hand, the body fat tile opens its own page, and
|
||||
|
||||
+37
-14
@@ -54,6 +54,9 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
|
||||
# name; see the note there for why guessing was wrong.
|
||||
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
|
||||
> /app/.pdfjs-version
|
||||
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
|
||||
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
|
||||
> /app/.canvas-version
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
|
||||
@@ -120,21 +123,41 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
|
||||
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
|
||||
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
|
||||
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
|
||||
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
|
||||
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
|
||||
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
|
||||
# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
|
||||
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
|
||||
# before the platform package, so it decides which binary runs.
|
||||
#
|
||||
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
|
||||
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
|
||||
# and this step used to take whichever `find | head -1` returned first. It
|
||||
# returned the old binary, which then sat next to the new loader: rendering
|
||||
# still worked, but `loadImage` called an `Image.decode` the old binary does
|
||||
# not have, and every preview thumbnail failed with "image.decode is not a
|
||||
# function". Name the version, and fail the build when the binary that lands
|
||||
# in the canvas dir is not that version's.
|
||||
COPY --from=builder /app/.canvas-version /tmp/.canvas-version
|
||||
RUN set -e; \
|
||||
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
|
||||
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
|
||||
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
|
||||
if [ -n "$CANVAS_DIR" ]; then \
|
||||
mkdir -p /app/node_modules/@napi-rs; \
|
||||
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
|
||||
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
|
||||
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
|
||||
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
|
||||
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
|
||||
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
|
||||
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
|
||||
rm -f /tmp/.canvas-version; \
|
||||
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
|
||||
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \
|
||||
if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \
|
||||
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \
|
||||
echo "Present instead:"; \
|
||||
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \
|
||||
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \
|
||||
mkdir -p /app/node_modules/@napi-rs; \
|
||||
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
|
||||
ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
|
||||
cp "$NODE_BIN" "$CANVAS_DIR/"; \
|
||||
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
|
||||
BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \
|
||||
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
|
||||
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
|
||||
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)"
|
||||
|
||||
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
|
||||
# path breaks in the standalone image), so the server does a runtime bare
|
||||
|
||||
@@ -129,9 +129,9 @@ Actively developed — new releases roughly weekly, issue reports and PRs welcom
|
||||
|
||||
HealthLog started as something I built for myself. That it has turned into a project other people use, care about and help shape still surprises me, and I am grateful for every part of it.
|
||||
|
||||
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama). A special thanks to [@BernhardBuckel](https://github.com/BernhardBuckel), who has been working through the lab screens one careful pull request at a time, with tests and a plan before every change.
|
||||
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama).
|
||||
|
||||
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
|
||||
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email, and especially to [@lutzkind](https://github.com/lutzkind), [@HercoGC](https://github.com/HercoGC), [@mills1975](https://github.com/mills1975), [@sreeramachandramurthy](https://github.com/sreeramachandramurthy), [@CritLoren](https://github.com/CritLoren), [@mbreitkreuz](https://github.com/mbreitkreuz), [@k-nacion](https://github.com/k-nacion), [@NebuPookins](https://github.com/NebuPookins), [@Nazza01](https://github.com/Nazza01), [@Cnote43](https://github.com/Cnote43) and [@juliaanvaneijndt](https://github.com/juliaanvaneijndt), whose reports shaped HealthLog again and again. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
|
||||
|
||||
And thank you to everyone who supported the project with a coffee or by buying the iPhone app. I don't name anyone here, but I read every message that comes with it. It pays for the servers and the Apple developer account, and it tells me that HealthLog is worth the evenings.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Every JSON route answers the same shape: `{ data, error }`, with `meta` on the e
|
||||
- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once.
|
||||
- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly.
|
||||
- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features.
|
||||
- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice.
|
||||
- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice.
|
||||
|
||||
## Data semantics worth knowing before you sync
|
||||
|
||||
|
||||
+151
-8
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.39.9
|
||||
version: 1.40.0
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -162,12 +162,14 @@ paths:
|
||||
pattern: ^[A-Za-z0-9_\-:.]{8,128}$
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the
|
||||
work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED
|
||||
rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no
|
||||
key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different
|
||||
route is a different key. While a first request under this key is still in flight, a second is refused with
|
||||
409 rather than queued."
|
||||
description: "Makes this write safe to retry. Send the same key with the same body again and the first response is
|
||||
replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as
|
||||
JSON with key order ignored; a different body under the same key is not a retry and runs as a new write,
|
||||
while the first response stays stored for retries of the first request. A key that does not match the
|
||||
pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a
|
||||
request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the
|
||||
same key on a different route is a different key. While a first request under this key is still in flight, a
|
||||
second with the same body is refused with 409 rather than queued."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -25855,6 +25857,28 @@ components:
|
||||
minimum: 10
|
||||
maximum: 600
|
||||
- type: "null"
|
||||
localReasoningEffort:
|
||||
description: Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off,
|
||||
`null` = Default (nothing sent). Omitted leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- none
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- type: "null"
|
||||
compatReasoningEffort:
|
||||
description: Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain.
|
||||
`none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- none
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- type: "null"
|
||||
description: "Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null`
|
||||
(or an empty string, on the string fields) clears the column. At least one recognised key must be present, or
|
||||
the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys
|
||||
@@ -27071,6 +27095,7 @@ components:
|
||||
properties:
|
||||
chain:
|
||||
minItems: 1
|
||||
maxItems: 7
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -27096,15 +27121,30 @@ components:
|
||||
maximum: 9007199254740991
|
||||
enabled:
|
||||
type: boolean
|
||||
reasoningEffort:
|
||||
description: Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already
|
||||
stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as
|
||||
`reasoning_effort`.
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- none
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- type: "null"
|
||||
required:
|
||||
- providerType
|
||||
- enabled
|
||||
additionalProperties: false
|
||||
description: The whole chain, in the order it should be walked. At least one entry and at most one per known provider
|
||||
type; a repeated type is refused.
|
||||
required:
|
||||
- chain
|
||||
additionalProperties: false
|
||||
description: Replaces the account's provider chain wholesale. No credential is written or read here — only which
|
||||
provider types exist, in what order, and which are enabled.
|
||||
provider types exist, in what order, which are enabled, and the reasoning setting of the Local and
|
||||
OpenAI-compatible entries.
|
||||
InsightsFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -36605,6 +36645,54 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
sensitive:
|
||||
anyOf:
|
||||
- anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
sexualActivity:
|
||||
type: boolean
|
||||
protectedSex:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
- type: "null"
|
||||
pregnancyTest:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NEGATIVE
|
||||
- POSITIVE
|
||||
- INDETERMINATE
|
||||
- type: "null"
|
||||
progesteroneTest:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NEGATIVE
|
||||
- POSITIVE
|
||||
- INDETERMINATE
|
||||
- type: "null"
|
||||
contraceptive:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- NONE
|
||||
- UNSPECIFIED
|
||||
- IMPLANT
|
||||
- INJECTION
|
||||
- IUD
|
||||
- INTRAVAGINAL_RING
|
||||
- ORAL
|
||||
- PATCH
|
||||
- EMERGENCY
|
||||
- type: "null"
|
||||
additionalProperties: false
|
||||
- type: string
|
||||
- type: "null"
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
@@ -36708,6 +36796,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- key
|
||||
- labelKey
|
||||
@@ -36744,6 +36836,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
@@ -36801,6 +36897,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
scaleMin:
|
||||
default: 1
|
||||
type: integer
|
||||
@@ -38388,6 +38488,10 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -38455,6 +38559,18 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
outcome:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
bodySite:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
laterality:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -38611,6 +38727,10 @@ components:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -48543,6 +48663,27 @@ components:
|
||||
- type: "null"
|
||||
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
|
||||
comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
|
||||
localReasoningEffort:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- none
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- type: "null"
|
||||
description: "#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null =
|
||||
Default: the key is not sent."
|
||||
compatReasoningEffort:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- none
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
- type: "null"
|
||||
description: "#1126 — the same for the OpenAI-compatible gateway entry."
|
||||
serverProviderHealth:
|
||||
type: string
|
||||
enum:
|
||||
@@ -48587,6 +48728,8 @@ components:
|
||||
- compatModel
|
||||
- hasCompatKey
|
||||
- responseTimeoutSeconds
|
||||
- localReasoningEffort
|
||||
- compatReasoningEffort
|
||||
- serverProviderHealth
|
||||
- serverProviderOffer
|
||||
- serverProviderConsent
|
||||
|
||||
@@ -171,8 +171,9 @@ tells you which rows to retry.
|
||||
## Retries and duplicates
|
||||
|
||||
Send an `Idempotency-Key` header with a value your automation can reproduce for
|
||||
the same reading. A retry with the same key returns the original response
|
||||
instead of writing a second row:
|
||||
the same reading. A retry with the same key and the same body returns the
|
||||
original response instead of writing a second row. A different body under the
|
||||
same key is a different reading and is written:
|
||||
|
||||
```yaml
|
||||
headers:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Blood pressure readings missing their diastolic half
|
||||
|
||||
## What happened
|
||||
|
||||
The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0)
|
||||
saves a manually entered blood pressure as two requests to
|
||||
`POST /api/measurements`: one `BLOOD_PRESSURE_SYS`, one
|
||||
`BLOOD_PRESSURE_DIA`, both carrying the same `Idempotency-Key`. Until the
|
||||
replay cache learned to compare request bodies, the server treated the second
|
||||
request as a retry of the first. It answered with the first request's cached
|
||||
`201` (header `X-Idempotent-Replay: true`) and never wrote the diastolic
|
||||
value. The app showed success, so nobody noticed.
|
||||
|
||||
The fix makes the cache replay only a request with the same body. A different
|
||||
body under the same key now runs normally. Nothing recovers the values that
|
||||
were never sent to the database: they exist only on the person's phone, if at
|
||||
all.
|
||||
|
||||
## Finding the affected readings
|
||||
|
||||
Read-only. Run it against the HealthLog database (with the bundled compose
|
||||
file: `docker compose exec db psql -U healthlog healthlog`). It lists every live systolic reading that has no
|
||||
live diastolic reading for the same account at the same instant.
|
||||
|
||||
<!-- bp-missing-diastolic:start -->
|
||||
|
||||
```sql
|
||||
SELECT u.username,
|
||||
m.id,
|
||||
m.measured_at,
|
||||
m.value AS systolic,
|
||||
m.source
|
||||
FROM measurements m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.type = 'BLOOD_PRESSURE_SYS'
|
||||
AND m.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM measurements d
|
||||
WHERE d.user_id = m.user_id
|
||||
AND d.measured_at = m.measured_at
|
||||
AND d.type = 'BLOOD_PRESSURE_DIA'
|
||||
AND d.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY u.username, m.measured_at;
|
||||
```
|
||||
|
||||
<!-- bp-missing-diastolic:end -->
|
||||
|
||||
Readings from the phone app carry `source = 'MANUAL'`. Rows from a synced
|
||||
source (Withings, Apple Health) that show up here are a different gap, not
|
||||
this one: those sources write both halves themselves.
|
||||
|
||||
## What to tell users
|
||||
|
||||
There is nothing the server can repair: the diastolic value never reached
|
||||
it. A person whose reading appears in the list can re-enter it: delete the
|
||||
incomplete reading, then add the blood pressure again with both values and
|
||||
the original date and time. From this release on, both halves are saved on
|
||||
the first try, from any app version.
|
||||
@@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
.locator('button[type="submit"]'),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
// #1126 — the Reasoning select on the Local and OpenAI-compatible forms
|
||||
// saves with the form, and at phone width it sits in the same column as
|
||||
// the model field without pushing the page sideways.
|
||||
test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const patchBodies: Array<Record<string, unknown>> = [];
|
||||
let providerState: Record<string, unknown> = {
|
||||
provider: "LOCAL",
|
||||
model: null,
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
responseTimeoutSeconds: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: "https://gateway.example.org/v1",
|
||||
compatModel: "qwen3",
|
||||
hasCompatKey: false,
|
||||
localReasoningEffort: null,
|
||||
compatReasoningEffort: null,
|
||||
};
|
||||
await page.route("**/api/insights/settings", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user/ai-provider", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
patchBodies.push(body);
|
||||
providerState = { ...providerState, ...body };
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: providerState, error: null }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
activeProvider: "local",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [
|
||||
{ providerType: "local", enabled: true, available: true },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
|
||||
await page.goto("/settings/ai?provider=local", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await providerHydrated;
|
||||
|
||||
const localForm = page.getByTestId("ai-provider-config-local");
|
||||
const localReasoning = page.locator("#ai-local-reasoning");
|
||||
await expect(localReasoning).toHaveValue("");
|
||||
// Same column, same width as the model select above it.
|
||||
const [modelBox, reasoningBox] = await Promise.all([
|
||||
page.locator("#ai-local-model").boundingBox(),
|
||||
localReasoning.boundingBox(),
|
||||
]);
|
||||
expect(reasoningBox?.x).toBe(modelBox?.x);
|
||||
expect(reasoningBox?.width).toBe(modelBox?.width);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await localReasoning.selectOption("none");
|
||||
await localForm.locator('button[type="submit"]').click();
|
||||
await expect.poll(() => patchBodies.length).toBe(1);
|
||||
expect(patchBodies[0].localReasoningEffort).toBe("none");
|
||||
|
||||
await page
|
||||
.getByTestId("ai-active-provider-select")
|
||||
.selectOption("openai-compatible");
|
||||
const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
|
||||
const compatReasoning = page.locator("#ai-compat-reasoning");
|
||||
await expect(compatReasoning).toHaveValue("");
|
||||
await compatReasoning.selectOption("high");
|
||||
await compatForm.locator('button[type="submit"]').click();
|
||||
await expect.poll(() => patchBodies.length).toBe(2);
|
||||
expect(patchBodies[1].compatReasoningEffort).toBe("high");
|
||||
expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Coach-Pläne",
|
||||
"description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.",
|
||||
"open": "Pläne öffnen"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Nachdenken",
|
||||
"hint": "Reasoning-Modelle wie Gemma oder Qwen denken nach, bevor sie antworten. Schaltest du es aus, bleibt das ganze Antwortbudget für die Antwort selbst.",
|
||||
"options": {
|
||||
"default": "Standard",
|
||||
"none": "Aus",
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
|
||||
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
|
||||
"previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)",
|
||||
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)",
|
||||
"restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)",
|
||||
"restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Coach plans",
|
||||
"description": "The plans you agreed with the Coach, to read or remove.",
|
||||
"open": "Open plans"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Reasoning",
|
||||
"hint": "Reasoning models such as Gemma or Qwen think before they answer. Off leaves the whole answer budget for the answer itself.",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"none": "Off",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
|
||||
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
|
||||
"previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)",
|
||||
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)",
|
||||
"restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)",
|
||||
"restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Planes del Coach",
|
||||
"description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.",
|
||||
"open": "Abrir planes"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Razonamiento",
|
||||
"hint": "Los modelos de razonamiento como Gemma o Qwen piensan antes de responder. Si lo desactivas, todo el presupuesto de respuesta se queda para la respuesta.",
|
||||
"options": {
|
||||
"default": "Predeterminado",
|
||||
"none": "Desactivado",
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
|
||||
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
|
||||
"previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)",
|
||||
"restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Plans du Coach",
|
||||
"description": "Les plans convenus avec le Coach, à lire ou à supprimer.",
|
||||
"open": "Ouvrir les plans"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Raisonnement",
|
||||
"hint": "Les modèles de raisonnement comme Gemma ou Qwen réfléchissent avant de répondre. Désactivé, tout le budget de réponse sert à la réponse elle-même.",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"none": "Désactivé",
|
||||
"low": "Faible",
|
||||
"medium": "Moyen",
|
||||
"high": "Élevé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.",
|
||||
"restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
|
||||
"previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)",
|
||||
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)",
|
||||
"restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)",
|
||||
"restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Piani del Coach",
|
||||
"description": "I piani concordati con il Coach, da leggere o rimuovere.",
|
||||
"open": "Apri i piani"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Ragionamento",
|
||||
"hint": "I modelli di ragionamento come Gemma o Qwen pensano prima di rispondere. Se lo disattivi, tutto il budget della risposta resta per la risposta stessa.",
|
||||
"options": {
|
||||
"default": "Predefinito",
|
||||
"none": "Disattivato",
|
||||
"low": "Basso",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
|
||||
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
|
||||
"previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)",
|
||||
"restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)",
|
||||
"restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "코치 계획",
|
||||
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
|
||||
"open": "계획 열기"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "추론",
|
||||
"hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.",
|
||||
"options": {
|
||||
"default": "기본값",
|
||||
"none": "끄기",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7535,7 +7546,11 @@
|
||||
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
|
||||
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
|
||||
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)"
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
|
||||
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
|
||||
"restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)",
|
||||
"restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)",
|
||||
"restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
|
||||
+16
-1
@@ -6081,6 +6081,17 @@
|
||||
"title": "Plany z Coachem",
|
||||
"description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.",
|
||||
"open": "Otwórz plany"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Rozumowanie",
|
||||
"hint": "Modele rozumujące, takie jak Gemma czy Qwen, myślą przed odpowiedzią. Po wyłączeniu cały budżet odpowiedzi idzie na samą odpowiedź.",
|
||||
"options": {
|
||||
"default": "Domyślnie",
|
||||
"none": "Wyłączone",
|
||||
"low": "Niskie",
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -7556,7 +7567,11 @@
|
||||
"restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
|
||||
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
|
||||
"previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)"
|
||||
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)",
|
||||
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
|
||||
"restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)",
|
||||
"restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)",
|
||||
"restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.39.9",
|
||||
"version": "1.40.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- What request a replay-cache cell answered, as a SHA-256 of the canonical
|
||||
-- request body scoped by method and path. A request that reuses a key with a
|
||||
-- different body is executed instead of being handed the first body's cached
|
||||
-- response. The body itself is never stored. Nullable: rows written before
|
||||
-- this release carry none, and a null never matches, so they stop replaying
|
||||
-- for anything but their own retries already in flight (they expire within
|
||||
-- 24 hours either way).
|
||||
ALTER TABLE "idempotency_keys" ADD COLUMN IF NOT EXISTS "request_fingerprint" TEXT;
|
||||
+13
-9
@@ -5545,15 +5545,19 @@ model ProviderHealth {
|
||||
/// (userId, key, method, path) so retries within the TTL replay the
|
||||
/// original outcome instead of producing a duplicate side-effect.
|
||||
model IdempotencyKey {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
method String
|
||||
path String
|
||||
responseStatus Int @map("response_status")
|
||||
responseBody String @map("response_body") // serialized JSON envelope
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
method String
|
||||
path String
|
||||
responseStatus Int @map("response_status")
|
||||
responseBody String @map("response_body") // serialized JSON envelope
|
||||
/// SHA-256 of the canonical request body, scoped by method and path. A
|
||||
/// lookup replays only when it matches; a different body under the same key
|
||||
/// runs normally. Null on rows written before it existed, which never match.
|
||||
requestFingerprint String? @map("request_fingerprint")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.39.9";
|
||||
/* @sw-version-fallback */ "v1.40.0";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Structural guard (#1126): `reasoning_effort` reaches a model only through
|
||||
* the setting the resolver bound to the provider instance.
|
||||
*
|
||||
* Default must send nothing, so the key may not appear on any wire except as
|
||||
* the bound value, and only the two clients whose endpoint a person chose may
|
||||
* carry it. Pinned here:
|
||||
*
|
||||
* 1. the key appears in exactly the Local client and the OpenAI client,
|
||||
* once each, and only as `reasoning_effort: this.reasoningEffort`;
|
||||
* 2. nothing but the binder assigns `.reasoningEffort`;
|
||||
* 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
|
||||
*
|
||||
* That clients are constructed only in `provider.ts` (so a resolver always
|
||||
* binds them) is frozen by `ai-call-timeout-guard.test.ts`.
|
||||
*
|
||||
* Its limit: it reads source text. A body built under a computed key, or a
|
||||
* resolver that binds through an alias, would slip it.
|
||||
* `reasoning-effort.test.ts` and `provider-reasoning-effort.test.ts` prove
|
||||
* the behaviour on the wire.
|
||||
*
|
||||
* Mutation check: hard-code `reasoning_effort: "none"` into either client,
|
||||
* set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
|
||||
* from `resolveProviderForTest`: each one goes red here.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function code(rel: string): string {
|
||||
return stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
}
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
|
||||
describe("reasoning_effort on the wire", () => {
|
||||
// As an object key: the published descriptions name the field in prose.
|
||||
const KEY = /\breasoning_effort["']?\s*:/g;
|
||||
const sites = files.filter((rel) => new RegExp(KEY).test(code(rel)));
|
||||
|
||||
it("appears in the Local and the OpenAI client only", () => {
|
||||
expect(sites.sort()).toEqual([
|
||||
"lib/ai/local-client.ts",
|
||||
"lib/ai/openai-client.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
|
||||
"%s sends it once, as the bound value",
|
||||
(rel) => {
|
||||
const source = code(rel);
|
||||
expect(source.match(new RegExp(KEY))?.length).toBe(1);
|
||||
expect(source).toMatch(
|
||||
/\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Where `.reasoningEffort` may be assigned, and why. */
|
||||
const ASSIGNMENT_SITES: Record<string, string> = {
|
||||
"lib/ai/reasoning-effort.ts":
|
||||
"The binder, the one place a provider instance gets its value.",
|
||||
"lib/ai/provider-chain.ts":
|
||||
"Writes the stored chain entry, not a provider instance.",
|
||||
};
|
||||
|
||||
describe("only the binder assigns it to a provider", () => {
|
||||
it("names every assignment site, with a reason", () => {
|
||||
const sites = files.filter((rel) =>
|
||||
/\.\s*reasoningEffort\s*=(?!=)/.test(code(rel)),
|
||||
);
|
||||
expect(sites.sort()).toEqual(Object.keys(ASSIGNMENT_SITES).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("every exported resolver binds it", () => {
|
||||
const source = code("lib/ai/provider.ts");
|
||||
const resolvers = source
|
||||
.split(/\nexport\s+/)
|
||||
.map((chunk) => {
|
||||
const m = /^async\s+function\s+(\w+)\s*\(/.exec(chunk);
|
||||
if (!m) return null;
|
||||
const header = chunk.slice(0, chunk.indexOf("{\n"));
|
||||
if (
|
||||
!/Promise<\s*(?:AIProvider|ProviderChainResolved\[\])\s*>/.test(header)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const end = chunk.indexOf("\n}\n");
|
||||
return { name: m[1], body: chunk.slice(header.length, end) };
|
||||
})
|
||||
.filter((r): r is { name: string; body: string } => r !== null);
|
||||
|
||||
it("finds the resolvers (an empty set is a failure, not a pass)", () => {
|
||||
expect(resolvers.map((r) => r.name).sort()).toEqual([
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
"resolveProviderForTest",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(resolvers.map((r) => r.name))(
|
||||
"%s calls bindReasoningEffort",
|
||||
(name) => {
|
||||
const resolver = resolvers.find((r) => r.name === name);
|
||||
expect(resolver?.body ?? "").toMatch(/\bbindReasoningEffort\s*\(/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Structural guard: the runtime image puts the @napi-rs/canvas native binary
|
||||
* of the version the app pins next to that version's loader.
|
||||
*
|
||||
* ## The failure this freezes (GitHub #1124)
|
||||
*
|
||||
* The pnpm store holds two canvas versions: the one in package.json and an
|
||||
* older one that pdf-parse's own pdfjs-dist pulls in. The runner stage picked
|
||||
* the musl binary with `find | head -1`, got the old one, and copied it into
|
||||
* the new loader's directory, where the loader looks first. PDF rendering
|
||||
* still worked, so nothing looked wrong; `loadImage` called an
|
||||
* `Image.decode` the old binary lacks, and every preview thumbnail failed
|
||||
* with "image.decode is not a function" on both architectures.
|
||||
*
|
||||
* ## What it proves and what it does not
|
||||
*
|
||||
* The builder records the resolved canvas version, the runner hoists by that
|
||||
* name, and the build stops when the copied binary's package is a different
|
||||
* version. Whether the binary loads is proved when the image runs; this file
|
||||
* reads the Dockerfile only.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const DOCKERFILE = readFileSync(join(process.cwd(), "Dockerfile"), "utf8");
|
||||
|
||||
function stage(name: string): string {
|
||||
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
|
||||
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
|
||||
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
|
||||
return next < 0
|
||||
? DOCKERFILE.slice(start)
|
||||
: DOCKERFILE.slice(start, start + 1 + next);
|
||||
}
|
||||
|
||||
describe("canvas binary hoist", () => {
|
||||
it("records the resolved canvas version in the builder", () => {
|
||||
expect(stage("builder")).toMatch(
|
||||
/require\('\/app\/node_modules\/@napi-rs\/canvas\/package\.json'\)\.version"[\s\\]*>\s*\/app\/\.canvas-version/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hoists the loader and the musl binary of that version only", () => {
|
||||
const runner = stage("runner");
|
||||
expect(runner).toContain(
|
||||
"COPY --from=builder /app/.canvas-version /tmp/.canvas-version",
|
||||
);
|
||||
expect(runner).toContain(
|
||||
'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/',
|
||||
);
|
||||
expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/");
|
||||
// A version-blind pick is the defect: any `canvas…@*` glob decides by
|
||||
// readdir order which of the store's copies wins.
|
||||
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
|
||||
});
|
||||
|
||||
it("fails the build when the copied binary is another version", () => {
|
||||
const runner = stage("runner");
|
||||
expect(runner).toMatch(
|
||||
/\[ "\$BIN_VERSION" = "\$CANVAS_VERSION" \] \|\| \{[^}]*exit 1; \}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -58,10 +58,13 @@ vi.mock("@/lib/profile/health-facts", () => ({
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
import { requestFingerprint } from "@/lib/idempotency";
|
||||
|
||||
/** The pending sentinel `findCached` recognises as "another call is running". */
|
||||
const PENDING_STATUS = 0;
|
||||
|
||||
const BODY = JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" });
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/anamnesis/facts", {
|
||||
method: "POST",
|
||||
@@ -69,7 +72,7 @@ function request(): NextRequest {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": "key-in-flight",
|
||||
},
|
||||
body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }),
|
||||
body: BODY,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +102,12 @@ describe("idempotency 409 envelope, over the real route", () => {
|
||||
it("serves `error` as a string when a request is already in flight", async () => {
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
id: "idem-1",
|
||||
// The same request in flight: a different body would run instead.
|
||||
requestFingerprint: requestFingerprint(
|
||||
"POST",
|
||||
"/api/anamnesis/facts",
|
||||
new TextEncoder().encode(BODY),
|
||||
),
|
||||
responseStatus: PENDING_STATUS,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
|
||||
@@ -53,6 +53,8 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
// #1126 — the stored chain, read for the reasoning settings it carries.
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -172,4 +174,93 @@ describe("PUT /api/insights/provider-chain", () => {
|
||||
{ providerType: "codex", priority: 2, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
// #1126 — the reasoning setting rides on the local / openai-compatible
|
||||
// entries. The chain editor only reorders and toggles, so saving the order
|
||||
// must not wipe a value it never sent.
|
||||
describe("reasoning setting", () => {
|
||||
function storedChain(chain: unknown) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProviderChain: chain,
|
||||
} as never);
|
||||
}
|
||||
async function put(chain: unknown[]) {
|
||||
vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never);
|
||||
return (PUT as (req: Request) => Promise<Response>)(
|
||||
jsonRequest({ chain }),
|
||||
);
|
||||
}
|
||||
function persisted() {
|
||||
return vi.mocked(prisma.user.update).mock.calls[0][0].data
|
||||
.aiProviderChain;
|
||||
}
|
||||
|
||||
it("persists a value sent on a local entry", async () => {
|
||||
storedChain(null);
|
||||
const res = await put([
|
||||
{ providerType: "local", enabled: true, reasoningEffort: "none" },
|
||||
{ providerType: "openai", enabled: true },
|
||||
]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(persisted()).toEqual([
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the stored value when the body leaves it out", async () => {
|
||||
storedChain([
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
await put([
|
||||
{ providerType: "openai-compatible", enabled: true },
|
||||
{ providerType: "openai", enabled: true },
|
||||
]);
|
||||
expect(persisted()).toEqual([
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{ providerType: "openai", priority: 2, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears it on null", async () => {
|
||||
storedChain([
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
]);
|
||||
await put([
|
||||
{ providerType: "local", enabled: true, reasoningEffort: null },
|
||||
]);
|
||||
expect(persisted()).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses it on an entry that cannot carry it", async () => {
|
||||
const res = await put([
|
||||
{ providerType: "anthropic", enabled: true, reasoningEffort: "none" },
|
||||
]);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -15,10 +13,9 @@ import {
|
||||
getLastWorkingProvider,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_TYPES,
|
||||
} from "@/lib/ai/provider-chain";
|
||||
import { parseProviderChain } from "@/lib/ai/provider-chain";
|
||||
import { isReasoningProviderType } from "@/lib/ai/reasoning-effort";
|
||||
import { providerChainPutSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5b — read-only chain summary for the Settings → AI
|
||||
@@ -85,29 +82,6 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const chainEntrySchema = z
|
||||
.object({
|
||||
providerType: z.enum(
|
||||
PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]],
|
||||
),
|
||||
// Priority is recomputed server-side from insertion order so a stale
|
||||
// client cannot persist a chain whose displayed order disagrees with
|
||||
// its priority field. The number is accepted (and may be present) but
|
||||
// ignored on the wire.
|
||||
priority: z.number().int().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const chainBodySchema = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(chainEntrySchema)
|
||||
.min(1, "Chain must contain at least one provider")
|
||||
.max(PROVIDER_CHAIN_TYPES.length, "Too many providers"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "insights.provider_chain.update" } });
|
||||
@@ -117,7 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = chainBodySchema.safeParse(body);
|
||||
const parsed = providerChainPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid provider chain payload",
|
||||
@@ -142,11 +116,35 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// Normalise priority to insertion order (1-based). UI hands us the
|
||||
// visual order it wants persisted; we make priority match so a later
|
||||
// GET reflects the same order without depending on the client's math.
|
||||
const normalised = parsed.data.chain.map((entry, idx) => ({
|
||||
providerType: entry.providerType,
|
||||
priority: idx + 1,
|
||||
enabled: entry.enabled,
|
||||
}));
|
||||
//
|
||||
// #1126 — a reasoning setting the body leaves out is carried over from the
|
||||
// stored entry of the same type, so the chain editor (which only reorders
|
||||
// and toggles) and an older client cannot wipe it by saving the order.
|
||||
// `null` clears it; the schema has already refused it on any type other
|
||||
// than `local` / `openai-compatible`.
|
||||
const stored = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiProviderChain: true },
|
||||
});
|
||||
const storedEffort = new Map(
|
||||
parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
|
||||
e.providerType,
|
||||
e.reasoningEffort ?? null,
|
||||
]),
|
||||
);
|
||||
const normalised = parsed.data.chain.map((entry, idx) => {
|
||||
const reasoningEffort = isReasoningProviderType(entry.providerType)
|
||||
? entry.reasoningEffort === undefined
|
||||
? (storedEffort.get(entry.providerType) ?? null)
|
||||
: entry.reasoningEffort
|
||||
: null;
|
||||
return {
|
||||
providerType: entry.providerType,
|
||||
priority: idx + 1,
|
||||
enabled: entry.enabled,
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// The PUT writes inside one interactive transaction; run its callback
|
||||
// against the same mocked client.
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
// v1.40 — the category and course helpers' defaults, which
|
||||
// `resetAllMocks` clears.
|
||||
vi.mocked(resolveCourseFields).mockResolvedValue(new Map());
|
||||
@@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
// v1.40 (#1024) — the window goes through the course writer, which
|
||||
// writes the row and the latest course in one transaction; the plain
|
||||
// row update no longer carries it.
|
||||
// v1.40 (#1024) — the window goes through the course writer, inside the
|
||||
// same transaction as the row update; the plain row update no longer
|
||||
// carries it.
|
||||
const call = lastUpdateCall();
|
||||
expect(call.data).not.toHaveProperty("endsOn");
|
||||
expect(setCurrentWindow).toHaveBeenCalledWith(
|
||||
@@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
medicationId: "m1",
|
||||
endsOn: new Date("2026-12-31"),
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -667,7 +667,12 @@ export const PUT = apiHandler(
|
||||
// Tombstone today's and future open pending rows of this medication.
|
||||
// Runs on a schedule replace (the old anchors are stale) and when intake
|
||||
// tracking is switched off (nothing is due any more).
|
||||
const tombstoneOpenSlots = async (): Promise<void> => {
|
||||
// The rollup days the tombstoned slots sat on, recomputed once the
|
||||
// transaction below has committed the tombstones.
|
||||
const staleRollupDays = new Set<string>();
|
||||
const tombstoneOpenSlots = async (
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<void> => {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -689,12 +694,12 @@ export const PUT = apiHandler(
|
||||
scheduledFor: { gte: todayStart },
|
||||
} as const;
|
||||
// Capture the affected slots BEFORE the tombstone so the compliance
|
||||
// rollups for those days can be recomputed immediately below —
|
||||
// rollups for those days can be recomputed after the commit —
|
||||
// `updateMany` does not return the touched rows. Without the
|
||||
// recompute a tombstoned pending kept counting as `scheduled` in
|
||||
// the rollup row until the next event-driven write for that day,
|
||||
// depressing the rate the cards read in the meantime.
|
||||
const tombstoned = await prisma.medicationIntakeEvent.findMany({
|
||||
const tombstoned = await db.medicationIntakeEvent.findMany({
|
||||
where: tombstoneWhere,
|
||||
select: { scheduledFor: true },
|
||||
});
|
||||
@@ -708,331 +713,337 @@ export const PUT = apiHandler(
|
||||
tombstonedTo = row.scheduledFor;
|
||||
}
|
||||
}
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
await db.medicationIntakeEvent.updateMany({
|
||||
where: tombstoneWhere,
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
// Recompute each affected rollup day (deduped via the user-tz day
|
||||
// key). Best-effort like every other rollup write-hook: a failure
|
||||
// is annotated, never blocks the schedule replace.
|
||||
const staleDayKeys = new Set(
|
||||
tombstoned.map((row) =>
|
||||
dayKeyForScheduledFor(row.scheduledFor, userTz),
|
||||
),
|
||||
);
|
||||
for (const dayKey of staleDayKeys) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
id,
|
||||
dayKey,
|
||||
userTz,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Deduped via the user-tz day key.
|
||||
for (const row of tombstoned) {
|
||||
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
|
||||
}
|
||||
};
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots();
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots();
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
|
||||
// course writer below writes them together with the course they
|
||||
// project, in one transaction, so the row and its courses cannot
|
||||
// disagree if one of the two writes fails.
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const withoutNotifications = { ...baseUpdateData } as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete withoutNotifications.notificationsEnabled;
|
||||
const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0;
|
||||
const hasNotificationsPatch = notificationsEnabled !== undefined;
|
||||
|
||||
const updateCandidates: Array<Record<string, unknown>> = [
|
||||
{ ...baseUpdateData, ...pausedAtPatch },
|
||||
];
|
||||
if (hasPausedAtPatch) {
|
||||
updateCandidates.push(baseUpdateData);
|
||||
}
|
||||
if (hasNotificationsPatch) {
|
||||
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
|
||||
if (hasPausedAtPatch) {
|
||||
updateCandidates.push(withoutNotifications);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.40 — everything the PUT writes to the medication, its schedule and
|
||||
// its course commits as one transaction. The course write can still be
|
||||
// refused after the check above (another request wrote a course in
|
||||
// between); with the row update committed first, the client got the
|
||||
// refusal while the name, the schedule and `oneShot` were already
|
||||
// changed. Now a refusal rolls all of it back. The rollup recompute and
|
||||
// the grace bridge stay outside: both read or write what this commit
|
||||
// settles, and neither may undo it.
|
||||
let medication;
|
||||
let lastUpdateErr: unknown;
|
||||
for (const candidate of updateCandidates) {
|
||||
try {
|
||||
medication = await prisma.medication.update({
|
||||
try {
|
||||
medication = await prisma.$transaction(async (tx) => {
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots(tx);
|
||||
await tx.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await tx.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await tx.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots(tx);
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
|
||||
// course writer below writes them together with the course they
|
||||
// project, in one transaction, so the row and its courses cannot
|
||||
// disagree if one of the two writes fails.
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const updated = await tx.medication.update({
|
||||
where: { id },
|
||||
data: candidate,
|
||||
data: { ...baseUpdateData, ...pausedAtPatch },
|
||||
include: { schedules: true },
|
||||
});
|
||||
break;
|
||||
} catch (updateErr) {
|
||||
lastUpdateErr = updateErr;
|
||||
}
|
||||
}
|
||||
if (!medication) throw lastUpdateErr;
|
||||
|
||||
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
|
||||
// single live marker that resume clears, so the paused window is
|
||||
// irrecoverable once resumed and every paused day's slot collapses to
|
||||
// "missed" in the compliance denominator. Mirror the live transition
|
||||
// onto an additive era record the rate paths read: a pause opens an era
|
||||
// (resumedAt null), a resume closes the latest open era. `userId` comes
|
||||
// from the authenticated session; the data object is built field-by-field.
|
||||
if (active === false && existing.active) {
|
||||
await prisma.medicationPauseEra.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
pausedAt: new Date(),
|
||||
resumedAt: null,
|
||||
},
|
||||
});
|
||||
} else if (active === true && !existing.active) {
|
||||
await prisma.medicationPauseEra.updateMany({
|
||||
where: { medicationId: id, userId: user.id, resumedAt: null },
|
||||
data: { resumedAt: new Date() },
|
||||
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
|
||||
// single live marker that resume clears, so the paused window is
|
||||
// irrecoverable once resumed and every paused day's slot collapses to
|
||||
// "missed" in the compliance denominator. Mirror the live transition
|
||||
// onto an additive era record the rate paths read: a pause opens an era
|
||||
// (resumedAt null), a resume closes the latest open era. `userId` comes
|
||||
// from the authenticated session; the data object is built field-by-field.
|
||||
if (active === false && existing.active) {
|
||||
await tx.medicationPauseEra.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
pausedAt: new Date(),
|
||||
resumedAt: null,
|
||||
},
|
||||
});
|
||||
} else if (active === true && !existing.active) {
|
||||
await tx.medicationPauseEra.updateMany({
|
||||
where: { medicationId: id, userId: user.id, resumedAt: null },
|
||||
data: { resumedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.40 (#1024) — write the window onto the latest course and
|
||||
// project it back, then serve the projected row.
|
||||
if (touchesWindow) {
|
||||
const projected = await setCurrentWindow(
|
||||
{
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { ...updated, ...projected };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
// A concurrent course write landed between the check above and the
|
||||
// course write. The transaction rolled back: nothing was written.
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Recompute each rollup day the tombstoned slots sat on, now that the
|
||||
// tombstones are committed. Best-effort like every other rollup
|
||||
// write-hook: a failure is annotated, never fails the edit.
|
||||
for (const dayKey of staleRollupDays) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
id,
|
||||
dayKey,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the v1.5.5 primary-schedule grace bridge after the
|
||||
@@ -1055,29 +1066,6 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.40 (#1024) — write the window onto the latest course and project it
|
||||
// back, then serve the projected row.
|
||||
if (touchesWindow) {
|
||||
let projected;
|
||||
try {
|
||||
projected = await setCurrentWindow({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
});
|
||||
} catch (err) {
|
||||
// A concurrent course write landed between the check above and this
|
||||
// transaction. Nothing of the window was written.
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
medication = { ...medication, ...projected };
|
||||
}
|
||||
|
||||
// v1.40 (#1041) — an iPhone app build that predates custom categories
|
||||
// decodes an unknown category as OTHER and sends it back on every edit,
|
||||
// which would overwrite the person's own category with OTHER. A native
|
||||
|
||||
@@ -623,3 +623,96 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #1126 — the Local and gateway entries' reasoning setting, written from the
|
||||
// provider forms onto the chain entry and read back from it.
|
||||
describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
|
||||
const patch = PATCH as (req: Request) => Promise<Response>;
|
||||
function storedChain(chain: unknown) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProviderChain: chain,
|
||||
} as never);
|
||||
}
|
||||
function written() {
|
||||
return vi.mocked(prisma.user.update).mock.calls[0]?.[0].data as
|
||||
Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
it("stores Off on the local entry of a chain that was never customised", async () => {
|
||||
storedChain(null);
|
||||
const res = await patch(
|
||||
patchRequest({ provider: "LOCAL", localReasoningEffort: "none" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const chain = written()?.aiProviderChain as Array<{
|
||||
providerType: string;
|
||||
reasoningEffort?: string;
|
||||
}>;
|
||||
expect(chain.find((e) => e.providerType === "local")).toMatchObject({
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
});
|
||||
expect(chain.filter((e) => e.reasoningEffort)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("holds the gateway's value on a disabled entry when the chain lacks it", async () => {
|
||||
storedChain([{ providerType: "local", priority: 1, enabled: true }]);
|
||||
await patch(patchRequest({ compatReasoningEffort: "high" }));
|
||||
expect(written()?.aiProviderChain).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not touch the chain when Default is restated on a default chain", async () => {
|
||||
storedChain(null);
|
||||
await patch(
|
||||
patchRequest({ provider: "LOCAL", localReasoningEffort: null }),
|
||||
);
|
||||
expect(written()).toEqual({ aiProvider: "LOCAL" });
|
||||
});
|
||||
|
||||
it("answers 200 for a body that only restates Default", async () => {
|
||||
storedChain(null);
|
||||
const res = await patch(patchRequest({ localReasoningEffort: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a value outside the five", async () => {
|
||||
const res = await patch(patchRequest({ localReasoningEffort: "max" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/user/ai-provider — reasoning setting (#1126)", () => {
|
||||
it("reads both values from the chain, null for Default", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: "LOCAL",
|
||||
aiProviderChain: [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "local",
|
||||
});
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as {
|
||||
data: { localReasoningEffort: unknown; compatReasoningEffort: unknown };
|
||||
};
|
||||
expect(body.data.localReasoningEffort).toBe("none");
|
||||
expect(body.data.compatReasoningEffort).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from "@/lib/sharing/provider-work-authority";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
parseProviderChain,
|
||||
serializeProviderChain,
|
||||
} from "@/lib/ai/provider-chain";
|
||||
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -42,6 +48,8 @@ export const GET = apiHandler(async () => {
|
||||
aiCompatModel: true,
|
||||
// v1.22 (#89)
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// #1126 — the reasoning settings live on the chain entries.
|
||||
aiProviderChain: true,
|
||||
// v1.38.19 — a managed profile is never the party that gives
|
||||
// consent for itself; the offer below has to know.
|
||||
managedProfileAt: true,
|
||||
@@ -141,6 +149,13 @@ export const GET = apiHandler(async () => {
|
||||
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
|
||||
// #1126 — the Local and gateway entries' reasoning settings (null =
|
||||
// Default), read from the chain where they are stored.
|
||||
localReasoningEffort: reasoningEffortFor(u?.aiProviderChain, "local"),
|
||||
compatReasoningEffort: reasoningEffortFor(
|
||||
u?.aiProviderChain,
|
||||
"openai-compatible",
|
||||
),
|
||||
// v1.38.19 — see the block above. A tri-state and two booleans;
|
||||
// no count, no timestamp and no other account is inferable from them.
|
||||
serverProviderHealth,
|
||||
@@ -297,7 +312,45 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
// ── #1126 — reasoning settings of the Local and gateway entries ──
|
||||
// Stored on the entry in `aiProviderChain` rather than in a column of their
|
||||
// own, so the setting is per entry and a chain can mix a thinking model with
|
||||
// one that does not know the field. See `chainWithReasoningEffort` for how a
|
||||
// default chain, or a provider absent from the chain, holds the value.
|
||||
const reasoningTouched =
|
||||
body.localReasoningEffort !== undefined ||
|
||||
body.compatReasoningEffort !== undefined;
|
||||
if (reasoningTouched) {
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiProviderChain: true },
|
||||
});
|
||||
let chain: unknown = current?.aiProviderChain ?? null;
|
||||
if (body.localReasoningEffort !== undefined) {
|
||||
chain = chainWithReasoningEffort(
|
||||
chain,
|
||||
"local",
|
||||
body.localReasoningEffort,
|
||||
);
|
||||
}
|
||||
if (body.compatReasoningEffort !== undefined) {
|
||||
chain = chainWithReasoningEffort(
|
||||
chain,
|
||||
"openai-compatible",
|
||||
body.compatReasoningEffort,
|
||||
);
|
||||
}
|
||||
// Written only when it changed: restating Default on a chain that was
|
||||
// never customised must not freeze today's default chain into the row.
|
||||
const before = serializeProviderChain(
|
||||
parseProviderChain(current?.aiProviderChain ?? null),
|
||||
);
|
||||
if (serializeProviderChain(parseProviderChain(chain)) !== before) {
|
||||
updates.aiProviderChain = chain;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0 && !reasoningTouched) {
|
||||
return apiError("No valid fields", 422, {
|
||||
errorCode: "ai_provider.no_fields",
|
||||
});
|
||||
@@ -317,7 +370,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
updates.aiBaseUrl = null;
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: updates });
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: updates });
|
||||
}
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
@@ -464,6 +464,18 @@ function catalogueLabel(
|
||||
if (catalogue === "symptomEpisodeReference") {
|
||||
return t("admin.section.backups.restoreSkippedSymptomEpisodeReference");
|
||||
}
|
||||
if (catalogue === "visitCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedVisitCiphertext");
|
||||
}
|
||||
if (catalogue === "vaccinationCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedVaccinationCiphertext");
|
||||
}
|
||||
if (catalogue === "moodLabelCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedMoodLabelCiphertext");
|
||||
}
|
||||
if (catalogue === "cycleCiphertext") {
|
||||
return t("admin.section.backups.restoreSkippedCycleCiphertext");
|
||||
}
|
||||
if (catalogue === "medicationTarget") {
|
||||
return t("admin.section.backups.restoreSkippedMedicationTarget");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import en from "../../../../../messages/en.json";
|
||||
import { CompatProviderForm } from "../compat-provider-form";
|
||||
import { LocalProviderForm } from "../local-provider-form";
|
||||
import type { UserAIProvider } from "../shared";
|
||||
|
||||
/**
|
||||
* #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It
|
||||
* sits right under the model field, offers exactly Default / Off / Low /
|
||||
* Medium / High, and seeds from the value the server stored on the entry.
|
||||
* Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`.
|
||||
*/
|
||||
|
||||
function provider(overrides: Partial<UserAIProvider>): UserAIProvider {
|
||||
return {
|
||||
provider: "LOCAL",
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://ollama.example.org:11434/v1",
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: "https://gateway.example.org/v1",
|
||||
compatModel: "qwen3",
|
||||
hasCompatKey: false,
|
||||
responseTimeoutSeconds: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The `<select>` element with the given id, as markup. */
|
||||
function selectMarkup(html: string, id: string): string {
|
||||
const match = new RegExp(`<select[^>]*\\bid="${id}"`).exec(html);
|
||||
expect(match, `no select #${id}`).not.toBeNull();
|
||||
const start = match!.index;
|
||||
return html.slice(start, html.indexOf("</select>", start));
|
||||
}
|
||||
|
||||
const OPTION_VALUES = ["", "none", "low", "medium", "high"];
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: "Local",
|
||||
id: "ai-local-reasoning",
|
||||
after: "ai-local-model",
|
||||
form: (p: UserAIProvider) => <LocalProviderForm userProvider={p} />,
|
||||
stored: (v: UserAIProvider["localReasoningEffort"]) => ({
|
||||
localReasoningEffort: v,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "OpenAI-compatible",
|
||||
id: "ai-compat-reasoning",
|
||||
after: "ai-compat-model",
|
||||
form: (p: UserAIProvider) => <CompatProviderForm userProvider={p} />,
|
||||
stored: (v: UserAIProvider["compatReasoningEffort"]) => ({
|
||||
compatReasoningEffort: v,
|
||||
provider: "OPENAI_COMPATIBLE",
|
||||
}),
|
||||
},
|
||||
])("$name form", ({ id, after, form, stored }) => {
|
||||
it("offers Default, Off, Low, Medium and High under the model field", () => {
|
||||
const html = render(form(provider({})));
|
||||
const select = selectMarkup(html, id);
|
||||
expect(
|
||||
[...select.matchAll(/<option value="([^"]*)"/g)].map((m) => m[1]),
|
||||
).toEqual(OPTION_VALUES);
|
||||
const r = en.settings.ai.reasoning;
|
||||
for (const label of Object.values(r.options)) {
|
||||
expect(select).toContain(`>${label}</option>`);
|
||||
}
|
||||
expect(html).toContain(`<label`);
|
||||
expect(html).toContain(`for="${id}"`);
|
||||
expect(html).toContain(r.label);
|
||||
expect(html).toContain(r.hint);
|
||||
expect(html.indexOf(`id="${id}"`)).toBeGreaterThan(
|
||||
html.indexOf(`id="${after}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds Default when nothing is stored, or a payload predates the field", () => {
|
||||
const select = selectMarkup(render(form(provider({}))), id);
|
||||
expect(select).toMatch(/<option value="" selected="">/);
|
||||
});
|
||||
|
||||
it("seeds the stored value", () => {
|
||||
const select = selectMarkup(render(form(provider(stored("none")))), id);
|
||||
expect(select).toMatch(/<option value="none" selected="">/);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
uiToLegacyProviderEnum,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "./shared";
|
||||
|
||||
export function CompatProviderForm({
|
||||
userProvider,
|
||||
@@ -32,6 +37,7 @@ export function CompatProviderForm({
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
@@ -40,13 +46,14 @@ export function CompatProviderForm({
|
||||
// render-time pattern the sibling forms use (no setState-in-effect).
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
|
||||
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}|${userProvider.compatReasoningEffort ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
setBaseUrl(userProvider?.compatBaseUrl ?? "");
|
||||
setModel(userProvider?.compatModel ?? "");
|
||||
setReasoning(userProvider?.compatReasoningEffort ?? null);
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -55,6 +62,7 @@ export function CompatProviderForm({
|
||||
provider: uiToLegacyProviderEnum("openai-compatible"),
|
||||
compatBaseUrl: baseUrl.trim() || null,
|
||||
compatModel: model.trim() || null,
|
||||
compatReasoningEffort: reasoning,
|
||||
};
|
||||
if (apiKey.trim()) body.compatKey = apiKey.trim();
|
||||
await apiPatch("/api/user/ai-provider", body);
|
||||
@@ -130,6 +138,11 @@ export function CompatProviderForm({
|
||||
{t("settings.ai.compat.modelHint")}
|
||||
</p>
|
||||
</div>
|
||||
<ReasoningEffortField
|
||||
id="ai-compat-reasoning"
|
||||
value={reasoning}
|
||||
onChange={setReasoning}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -17,10 +17,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
uiToLegacyProviderEnum,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "./shared";
|
||||
|
||||
@@ -36,17 +38,20 @@ export function LocalProviderForm({
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [modelChoice, setModelChoice] = useState<string>("");
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
|
||||
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}|${userProvider.localReasoningEffort ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
// Stored on the chain entry, so it seeds whichever provider is selected.
|
||||
setReasoning(userProvider?.localReasoningEffort ?? null);
|
||||
if (userProvider?.provider === "LOCAL") {
|
||||
setBaseUrl(userProvider.baseUrl ?? "");
|
||||
const saved = userProvider.model ?? "";
|
||||
@@ -71,6 +76,7 @@ export function LocalProviderForm({
|
||||
provider: uiToLegacyProviderEnum("local"),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
model: effectiveModel || null,
|
||||
localReasoningEffort: reasoning,
|
||||
};
|
||||
if (apiKey.trim()) body.localKey = apiKey.trim();
|
||||
await apiPatch("/api/user/ai-provider", body);
|
||||
@@ -168,6 +174,11 @@ export function LocalProviderForm({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ReasoningEffortField
|
||||
id="ai-local-reasoning"
|
||||
value={reasoning}
|
||||
onChange={setReasoning}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
|
||||
* Same Label + NativeSelect + hint layout as the model field above it.
|
||||
* The empty value is Default: nothing is sent and the model decides.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { ReasoningEffort } from "./shared";
|
||||
|
||||
export function ReasoningEffortField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: ReasoningEffort | null;
|
||||
onChange: (next: ReasoningEffort | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{t("settings.ai.reasoning.label")}</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
|
||||
)
|
||||
}
|
||||
className="mt-1"
|
||||
aria-describedby={`${id}-hint`}
|
||||
>
|
||||
<option value="">{t("settings.ai.reasoning.options.default")}</option>
|
||||
<option value="none">{t("settings.ai.reasoning.options.none")}</option>
|
||||
<option value="low">{t("settings.ai.reasoning.options.low")}</option>
|
||||
<option value="medium">
|
||||
{t("settings.ai.reasoning.options.medium")}
|
||||
</option>
|
||||
<option value="high">{t("settings.ai.reasoning.options.high")}</option>
|
||||
</NativeSelect>
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.reasoning.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
* this module under `src/components/settings/ai/`.
|
||||
*/
|
||||
|
||||
import type { ReasoningEffort } from "@/lib/ai/reasoning-effort";
|
||||
|
||||
export interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
codexConnectedAt: string | null;
|
||||
@@ -40,8 +42,15 @@ export interface UserAIProvider {
|
||||
hasCompatKey: boolean;
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: number | null;
|
||||
// #1126 — reasoning setting of the Local and gateway entries (null =
|
||||
// Default). Absent on a payload from a server older than the field.
|
||||
localReasoningEffort?: ReasoningEffort | null;
|
||||
compatReasoningEffort?: ReasoningEffort | null;
|
||||
}
|
||||
|
||||
/** #1126 — the `reasoning_effort` values; "Off" is `none`. */
|
||||
export type { ReasoningEffort };
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
|
||||
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ManualWorkoutForm({
|
||||
// a submit after an edit updates the row the first submit stored.
|
||||
const [externalId] = useState(newManualWorkoutExternalId);
|
||||
const [draft, setDraft] = useState<ManualWorkoutDraft>(() =>
|
||||
emptyManualWorkoutDraft(new Date(), timezone),
|
||||
emptyManualWorkoutDraft(),
|
||||
);
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<ManualWorkoutField, string>>
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
withIdempotency,
|
||||
defaultUserIdResolver,
|
||||
isCachableStatus,
|
||||
canonicalJson,
|
||||
requestFingerprint,
|
||||
} from "../idempotency";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
@@ -81,6 +83,13 @@ function makeRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/** The fingerprint `makeRequest("POST", …)` produces. */
|
||||
const OK_BODY_FINGERPRINT = requestFingerprint(
|
||||
"POST",
|
||||
"/api/example",
|
||||
new TextEncoder().encode(JSON.stringify({ ok: true })),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
||||
@@ -144,6 +153,11 @@ describe("withIdempotency", () => {
|
||||
data: { responseBody: string; responseStatus: number };
|
||||
}
|
||||
).data;
|
||||
const claimedFingerprint = (
|
||||
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
||||
data: { requestFingerprint: string };
|
||||
}
|
||||
).data.requestFingerprint;
|
||||
|
||||
// Second call: cache hit returns persisted envelope.
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
||||
@@ -152,6 +166,7 @@ describe("withIdempotency", () => {
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: claimedFingerprint,
|
||||
responseStatus: persistedBody.responseStatus,
|
||||
responseBody: persistedBody.responseBody,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
@@ -296,6 +311,7 @@ describe("withIdempotency concurrency claim (A3)", () => {
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: OK_BODY_FINGERPRINT,
|
||||
responseStatus: 0,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
@@ -928,3 +944,227 @@ describe("the record-session fence sits above the replay cache", () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("request fingerprint", () => {
|
||||
const enc = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
it("sorts object keys recursively and keeps array order", () => {
|
||||
expect(canonicalJson({ b: 1, a: { d: [3, 1], c: null } })).toBe(
|
||||
'{"a":{"c":null,"d":[3,1]},"b":1}',
|
||||
);
|
||||
expect(canonicalJson([{ y: 1, x: 2 }, "s"])).toBe('[{"x":2,"y":1},"s"]');
|
||||
});
|
||||
|
||||
it("treats the same JSON object in a different key order as one request", () => {
|
||||
const a = requestFingerprint("POST", "/p", enc('{"type":"X","value":1}'));
|
||||
const b = requestFingerprint(
|
||||
"POST",
|
||||
"/p",
|
||||
enc('{ "value": 1, "type": "X" }'),
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("tells nested values and array order apart", () => {
|
||||
const base = requestFingerprint("POST", "/p", enc('{"a":{"b":[1,2]}}'));
|
||||
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[2,1]}}'))).not.toBe(
|
||||
base,
|
||||
);
|
||||
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[1,3]}}'))).not.toBe(
|
||||
base,
|
||||
);
|
||||
});
|
||||
|
||||
it("tells the two halves of a blood pressure apart", () => {
|
||||
const sys = requestFingerprint(
|
||||
"POST",
|
||||
"/api/measurements",
|
||||
enc('{"type":"BLOOD_PRESSURE_SYS","value":120}'),
|
||||
);
|
||||
const dia = requestFingerprint(
|
||||
"POST",
|
||||
"/api/measurements",
|
||||
enc('{"type":"BLOOD_PRESSURE_DIA","value":80}'),
|
||||
);
|
||||
expect(sys).not.toBe(dia);
|
||||
});
|
||||
|
||||
it("reads a rebuilt multipart form with a fresh boundary as the same upload", () => {
|
||||
const form = (boundary: string, name: string) =>
|
||||
enc(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4 body\r\n--${boundary}--\r\n`,
|
||||
);
|
||||
const type = (boundary: string) =>
|
||||
`multipart/form-data; boundary=${boundary}`;
|
||||
const first = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-a1", "brief.pdf"),
|
||||
null,
|
||||
type("----formdata-a1"),
|
||||
);
|
||||
const retry = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-b2", "brief.pdf"),
|
||||
null,
|
||||
type("----formdata-b2"),
|
||||
);
|
||||
expect(retry).toBe(first);
|
||||
const other = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-b2", "other.pdf"),
|
||||
null,
|
||||
type("----formdata-b2"),
|
||||
);
|
||||
expect(other).not.toBe(first);
|
||||
});
|
||||
|
||||
it("hashes a non-JSON or empty body as its raw bytes", () => {
|
||||
const text = requestFingerprint("POST", "/p", enc("not json"));
|
||||
expect(text).toBe(requestFingerprint("POST", "/p", enc("not json")));
|
||||
expect(text).not.toBe(requestFingerprint("POST", "/p", enc("not json!")));
|
||||
const empty = requestFingerprint("DELETE", "/p", new Uint8Array());
|
||||
expect(empty).toBe(requestFingerprint("DELETE", "/p", new Uint8Array()));
|
||||
expect(empty).not.toBe(text);
|
||||
// Invalid UTF-8 is raw bytes, not an error.
|
||||
expect(() =>
|
||||
requestFingerprint("POST", "/p", new Uint8Array([0xff, 0xfe])),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("is scoped by method and path", () => {
|
||||
const body = enc('{"a":1}');
|
||||
const base = requestFingerprint("POST", "/p", body);
|
||||
expect(requestFingerprint("PUT", "/p", body)).not.toBe(base);
|
||||
expect(requestFingerprint("POST", "/q", body)).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withIdempotency with a different body under one key", () => {
|
||||
function postWith(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/example", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": "abc-12345678",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function liveRow(fingerprint: string | null, responseStatus: number) {
|
||||
return {
|
||||
id: "idem-1",
|
||||
userId: "u-1",
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: fingerprint,
|
||||
responseStatus,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a completed row for another body", "f".repeat(64), 201],
|
||||
["an in-flight claim for another body", "f".repeat(64), 0],
|
||||
["a legacy row without a fingerprint", null, 201],
|
||||
])(
|
||||
"runs the handler without claiming or caching over %s",
|
||||
async (_label, fingerprint, status) => {
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce(
|
||||
liveRow(fingerprint, status),
|
||||
);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(
|
||||
handler,
|
||||
async () => "u-1",
|
||||
);
|
||||
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("X-Idempotent-Replay")).toBeNull();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.delete).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("runs the handler when a racing claim for another body wins the insert", async () => {
|
||||
vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce(
|
||||
Object.assign(new Error("unique"), { code: "P2002" }),
|
||||
);
|
||||
// First lookup: nothing yet. Second, after the collision: the other body.
|
||||
vi.mocked(prisma.idempotencyKey.findUnique)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(liveRow("f".repeat(64), 0));
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the fingerprint, never the body, on the claim", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
await wrapped(postWith({ type: "BLOOD_PRESSURE_SYS", value: 123 }));
|
||||
|
||||
const claim = (
|
||||
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
).data;
|
||||
expect(claim.requestFingerprint).toBe(
|
||||
requestFingerprint(
|
||||
"POST",
|
||||
"/api/example",
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ type: "BLOOD_PRESSURE_SYS", value: 123 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(JSON.stringify(claim)).not.toContain("BLOOD_PRESSURE_SYS");
|
||||
});
|
||||
|
||||
it("leaves the body readable for the handler", async () => {
|
||||
const handler = vi.fn(async (req: NextRequest) =>
|
||||
NextResponse.json({ data: await req.json(), error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(postWith({ type: "WEIGHT", value: 70 }));
|
||||
expect((await res.json()).data).toEqual({ type: "WEIGHT", value: 70 });
|
||||
});
|
||||
|
||||
it("reads at most a bounded prefix of a large body and still hands the whole body on", async () => {
|
||||
const big = "x".repeat(3 * 1024 * 1024);
|
||||
const handler = vi.fn(async (req: NextRequest) =>
|
||||
NextResponse.json({ data: (await req.text()).length, error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(
|
||||
new NextRequest("http://localhost/api/example", {
|
||||
method: "POST",
|
||||
headers: { "idempotency-key": "abc-12345678" },
|
||||
body: big,
|
||||
}),
|
||||
);
|
||||
expect((await res.json()).data).toBe(big.length);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isResponseFormatRejection } from "../json-dialect";
|
||||
|
||||
describe("isResponseFormatRejection", () => {
|
||||
it("reads a complaint about response_format as a JSON-mode rejection", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(400, "Unsupported value: 'response_format'"),
|
||||
).toBe(true);
|
||||
expect(isResponseFormatRejection(422, "unknown parameter")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not read a complaint about reasoning_effort as one", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(
|
||||
400,
|
||||
"Unrecognized request argument supplied: reasoning_effort",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isResponseFormatRejection(400, "unknown field `reasoning effort`"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still flips when both fields are named", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(
|
||||
400,
|
||||
"unknown parameters: response_format, reasoning_effort",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores server errors", () => {
|
||||
expect(isResponseFormatRejection(500, "response_format")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* #1126 — every exported resolver hands out providers carrying their own
|
||||
* chain entry's reasoning setting, and the request each one then sends
|
||||
* carries exactly that value. A chain mixing a thinking model with one that
|
||||
* does not know the field is the case the setting exists for.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/logging/context")>()),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: (url: string, init: RequestInit) => fetch(url, init),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveProvider,
|
||||
resolveProviderChain,
|
||||
resolveProviderForTest,
|
||||
} from "../provider";
|
||||
import { resetLocalJsonDialectCache } from "../local-client";
|
||||
import { singleUserTurn, type AIProvider } from "../types";
|
||||
|
||||
const ROW = {
|
||||
aiProvider: "LOCAL",
|
||||
aiModel: "gemma4:12b",
|
||||
aiBaseUrl: "https://ollama.example.org/v1",
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "openai-key",
|
||||
aiCompatBaseUrl: "https://gateway.example.org/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama3",
|
||||
role: "USER",
|
||||
useCentralCodex: false,
|
||||
managedProfileAt: null,
|
||||
aiResponseTimeoutSeconds: null,
|
||||
};
|
||||
|
||||
function withChain(aiProviderChain: unknown) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProviderChain,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
const MIXED = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{ providerType: "openai-compatible", priority: 2, enabled: true },
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
|
||||
/** The `reasoning_effort` the provider actually puts on the wire. */
|
||||
async function sentEffort(provider: AIProvider): Promise<unknown> {
|
||||
const mock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "ok" }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await provider.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
vi.unstubAllGlobals();
|
||||
const body = JSON.parse(mock.mock.calls[0][1].body as string);
|
||||
return "reasoning_effort" in body ? body.reasoning_effort : "(absent)";
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetLocalJsonDialectCache();
|
||||
});
|
||||
|
||||
describe("resolveProviderChain binds each entry's own setting", () => {
|
||||
it("a mixed chain sends none, nothing, nothing", async () => {
|
||||
withChain(MIXED);
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain.map((e) => e.providerType)).toEqual([
|
||||
"local",
|
||||
"openai-compatible",
|
||||
"openai",
|
||||
]);
|
||||
expect(chain.map((e) => e.instance.reasoningEffort)).toEqual([
|
||||
"none",
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const sent = [];
|
||||
for (const entry of chain) sent.push(await sentEffort(entry.instance));
|
||||
expect(sent).toEqual(["none", "(absent)", "(absent)"]);
|
||||
});
|
||||
|
||||
it("the gateway's own value reaches the gateway", async () => {
|
||||
withChain([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(await sentEffort(chain[0].instance)).toBe("(absent)");
|
||||
expect(await sentEffort(chain[1].instance)).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProvider", () => {
|
||||
it("binds the selected Local provider's setting", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProvider("user-1");
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("reaches a selected gateway whose value sits on a disabled entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiProviderChain: [
|
||||
{ providerType: "codex", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const provider = await resolveProvider("user-1");
|
||||
expect(provider.type).toBe("openai-compatible");
|
||||
expect(await sentEffort(provider)).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderForTest — the test button runs with the setting", () => {
|
||||
it("testing the saved config (empty body) uses the saved provider's entry", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {});
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("with no provider selected, the test runs the chain head with its value", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProvider: null,
|
||||
aiProviderChain: MIXED,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
const provider = await resolveProviderForTest("user-1", {});
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("testing a named Local provider uses its entry's value", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {
|
||||
provider: "LOCAL",
|
||||
});
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("testing the personal OpenAI key never sends it", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {
|
||||
provider: "OPENAI",
|
||||
});
|
||||
expect(await sentEffort(provider)).toBe("(absent)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* #1126 — the per-entry reasoning setting on the wire and in the schemas.
|
||||
*
|
||||
* Default must leave the request body byte-identical to what a setup that
|
||||
* never touched the setting has always sent, so the Default cases compare the
|
||||
* whole body string against a literal captured before the setting existed.
|
||||
* Every other choice adds exactly one key, `reasoning_effort`, and only on the
|
||||
* Local client and the OpenAI-compatible gateway.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../local-host-allowlist", () => ({
|
||||
aiEgressPolicyFor: () => ({}),
|
||||
isLocalAiHostAllowed: () => false,
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
// The pinned tags dial through undici's own fetch; route every call to the
|
||||
// stubbed global so the body can be read whatever the egress policy.
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: (url: string, init: RequestInit) => fetch(url, init),
|
||||
}));
|
||||
|
||||
import { bindReasoningEffort, reasoningEffortFor } from "../reasoning-effort";
|
||||
import {
|
||||
LocalOpenAICompatibleClient,
|
||||
resetLocalJsonDialectCache,
|
||||
} from "../local-client";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
serializeProviderChain,
|
||||
} from "../provider-chain";
|
||||
import { singleUserTurn, type AIProvider } from "../types";
|
||||
import {
|
||||
aiProviderPatchSchema,
|
||||
providerChainEntrySchema,
|
||||
providerChainPutSchema,
|
||||
} from "@/lib/validations/ai-provider";
|
||||
|
||||
const PARAMS = {
|
||||
...singleUserTurn({ system: "s", user: "u" }),
|
||||
maxTokens: 250,
|
||||
};
|
||||
|
||||
function okFetch(content = "ok") {
|
||||
const mock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 5 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function sentBody(mock: ReturnType<typeof okFetch>): string {
|
||||
return mock.mock.calls[0][1].body as string;
|
||||
}
|
||||
|
||||
function localClient() {
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "gemma4:12b",
|
||||
baseUrl: "http://ollama.example.org:11434/v1",
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayClient() {
|
||||
return new OpenAIClient({
|
||||
apiKey: "",
|
||||
model: "qwen3",
|
||||
baseUrl: "https://gateway.example.org/v1",
|
||||
providerType: "openai-compatible",
|
||||
});
|
||||
}
|
||||
|
||||
// Captured from the clients before the setting existed. Default must keep
|
||||
// sending exactly this.
|
||||
const LOCAL_BODY_BEFORE =
|
||||
'{"model":"gemma4:12b","messages":[{"role":"system","content":"s"},{"role":"user","content":"Return strict JSON only, no markdown, no commentary.\\n\\nu"}],"temperature":0.3,"max_tokens":250}';
|
||||
const GATEWAY_BODY_BEFORE =
|
||||
'{"model":"qwen3","messages":[{"role":"system","content":"s"},{"role":"user","content":"u"}],"max_tokens":250,"temperature":0.3}';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetLocalJsonDialectCache();
|
||||
});
|
||||
|
||||
describe("Local client", () => {
|
||||
it("Default sends the body it always sent, byte for byte", async () => {
|
||||
const mock = okFetch();
|
||||
await localClient().generateCompletion(PARAMS);
|
||||
expect(sentBody(mock)).toBe(LOCAL_BODY_BEFORE);
|
||||
expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort");
|
||||
});
|
||||
|
||||
it("Off sends reasoning_effort none and nothing else new", async () => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = "none";
|
||||
await client.generateCompletion(PARAMS);
|
||||
const body = JSON.parse(sentBody(mock));
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
const { reasoning_effort: _dropped, ...rest } = body;
|
||||
expect(JSON.stringify(rest)).toBe(LOCAL_BODY_BEFORE);
|
||||
});
|
||||
|
||||
it.each(["low", "medium", "high"] as const)("%s is sent as is", async (v) => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = v;
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe(v);
|
||||
});
|
||||
|
||||
it("the streaming request carries it too", async () => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = "none";
|
||||
// A buffered answer to a stream request falls back cleanly; only the
|
||||
// first request body matters here.
|
||||
await client.generateCompletionStream(PARAMS, () => {}).catch(() => {});
|
||||
const body = JSON.parse(sentBody(mock));
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI-compatible gateway", () => {
|
||||
it("Default sends the body it always sent, byte for byte", async () => {
|
||||
const mock = okFetch();
|
||||
await gatewayClient().generateCompletion(PARAMS);
|
||||
expect(sentBody(mock)).toBe(GATEWAY_BODY_BEFORE);
|
||||
});
|
||||
|
||||
it("Off sends reasoning_effort none", async () => {
|
||||
const mock = okFetch();
|
||||
const client = gatewayClient();
|
||||
client.reasoningEffort = "none";
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe("none");
|
||||
});
|
||||
|
||||
it("the OpenAI and Codex tags never send it, whatever is stamped", async () => {
|
||||
for (const providerType of ["admin-key", "codex"] as const) {
|
||||
vi.unstubAllGlobals();
|
||||
const mock = okFetch();
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://gateway.example.org/v1",
|
||||
providerType,
|
||||
});
|
||||
client.reasoningEffort = "high";
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindReasoningEffort", () => {
|
||||
const chain = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
|
||||
it("stamps each entry's own value, keyed on the instance type", () => {
|
||||
expect(bindReasoningEffort(localClient(), chain).reasoningEffort).toBe(
|
||||
"none",
|
||||
);
|
||||
expect(bindReasoningEffort(gatewayClient(), chain).reasoningEffort).toBe(
|
||||
"high",
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps null on every other provider", () => {
|
||||
const openai = new OpenAIClient({
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
const anthropic: AIProvider = new AnthropicClient({
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
});
|
||||
expect(bindReasoningEffort(openai, chain).reasoningEffort).toBeNull();
|
||||
expect(bindReasoningEffort(anthropic, chain).reasoningEffort).toBeNull();
|
||||
});
|
||||
|
||||
it("stamps null for a chain that was never customised", () => {
|
||||
expect(bindReasoningEffort(localClient(), null).reasoningEffort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the stored chain", () => {
|
||||
it("keeps a valid value on local / openai-compatible and drops it elsewhere", () => {
|
||||
const parsed = parseProviderChain([
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
{
|
||||
providerType: "openai",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
reasoningEffort: "max",
|
||||
},
|
||||
]);
|
||||
expect(parsed.map((e) => e.reasoningEffort)).toEqual([
|
||||
"low",
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(serializeProviderChain(parsed)).toBe(
|
||||
'[{"providerType":"local","priority":1,"enabled":true,"reasoningEffort":"low"},{"providerType":"openai","priority":2,"enabled":true},{"providerType":"openai-compatible","priority":3,"enabled":true}]',
|
||||
);
|
||||
});
|
||||
|
||||
it("serialises a chain without the setting exactly as before", () => {
|
||||
expect(serializeProviderChain(PROVIDER_CHAIN_DEFAULT)).not.toContain(
|
||||
"reasoningEffort",
|
||||
);
|
||||
});
|
||||
|
||||
it("materialises the default chain to hold a value", () => {
|
||||
const next = chainWithReasoningEffort(null, "local", "none");
|
||||
expect(next.map((e) => e.providerType)).toEqual(
|
||||
PROVIDER_CHAIN_DEFAULT.map((e) => e.providerType),
|
||||
);
|
||||
expect(reasoningEffortFor(next, "local")).toBe("none");
|
||||
});
|
||||
|
||||
it("holds a value for a provider absent from the chain on a disabled entry", () => {
|
||||
const stored = [{ providerType: "codex", priority: 7, enabled: true }];
|
||||
const next = chainWithReasoningEffort(stored, "openai-compatible", "high");
|
||||
expect(next).toEqual([
|
||||
{ providerType: "codex", priority: 7, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 8,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears without adding an entry", () => {
|
||||
const stored = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
];
|
||||
expect(chainWithReasoningEffort(stored, "local", null)).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
]);
|
||||
expect(chainWithReasoningEffort(stored, "openai-compatible", null)).toEqual(
|
||||
[
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schemas", () => {
|
||||
const VALUES = [null, "none", "low", "medium", "high"] as const;
|
||||
|
||||
it.each(["local", "openai-compatible"])(
|
||||
"a %s chain entry accepts Default, Off, Low, Medium and High",
|
||||
(providerType) => {
|
||||
for (const reasoningEffort of VALUES) {
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({
|
||||
providerType,
|
||||
enabled: true,
|
||||
reasoningEffort,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a value outside the five", () => {
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({
|
||||
providerType: "local",
|
||||
enabled: true,
|
||||
reasoningEffort: "max",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["openai", "anthropic", "codex", "admin-openai", "admin-codex"])(
|
||||
"refuses the setting on a %s entry, null included",
|
||||
(providerType) => {
|
||||
for (const reasoningEffort of VALUES) {
|
||||
const result = providerChainEntrySchema.safeParse({
|
||||
providerType,
|
||||
enabled: true,
|
||||
reasoningEffort,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(["reasoningEffort"]);
|
||||
}
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({ providerType, enabled: true })
|
||||
.success,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("the chain body still accepts a chain without the setting", () => {
|
||||
expect(
|
||||
providerChainPutSchema.safeParse({
|
||||
chain: [{ providerType: "local", priority: 1, enabled: true }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("the provider PATCH accepts the five values for both entries", () => {
|
||||
for (const v of VALUES) {
|
||||
expect(
|
||||
aiProviderPatchSchema.safeParse({
|
||||
localReasoningEffort: v,
|
||||
compatReasoningEffort: v,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
aiProviderPatchSchema.safeParse({ localReasoningEffort: "max" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -56,13 +56,21 @@ export function resetJsonModeDialectCache(): void {
|
||||
/**
|
||||
* True when a 4xx error body reads as "this endpoint rejects the
|
||||
* `response_format` field" — either it names the field outright or it
|
||||
* complains about an unknown/unexpected parameter.
|
||||
* complains about an unknown/unexpected parameter. A complaint that names
|
||||
* `reasoning_effort` and not `response_format` is about the reasoning field,
|
||||
* so it must not switch the endpoint's JSON mode off.
|
||||
*/
|
||||
export function isResponseFormatRejection(
|
||||
status: number,
|
||||
bodyExcerpt: string,
|
||||
): boolean {
|
||||
if (status < 400 || status >= 500) return false;
|
||||
if (
|
||||
/reasoning[\s_-]*effort/i.test(bodyExcerpt) &&
|
||||
!/response_format/i.test(bodyExcerpt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/response_format/i.test(bodyExcerpt) ||
|
||||
/(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resetJsonModeDialectCache,
|
||||
type JsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
|
||||
interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
@@ -108,6 +109,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
readonly type = "local" as const;
|
||||
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
|
||||
responseTimeoutSeconds: number | null = null;
|
||||
/** See `AIProvider.reasoningEffort`; stamped by the resolver. */
|
||||
reasoningEffort: ReasoningEffort | null = null;
|
||||
/** Local servers commonly reject an unknown `tools` field — never send it. */
|
||||
readonly supportsTools = false;
|
||||
private config: LocalClientConfig;
|
||||
@@ -164,6 +167,12 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// most OpenAI-compatible local servers honour it; servers that
|
||||
// ignore it simply disregard the field. Omitted when unset.
|
||||
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
||||
// #1126 — the entry's reasoning setting. Default sends nothing, so the
|
||||
// body of a setup that never touched it stays byte-identical; "Off" is
|
||||
// `none`, which stops a thinking model spending the answer budget.
|
||||
...(this.reasoningEffort
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
// v1.28.28 (#470) — the JSON surfaces opt in via `responseFormat:
|
||||
// "json"`. Default dialect sends the STANDARD OpenAI field (the
|
||||
// top-level Ollama-native `format: "json"` this client used to send
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rememberJsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import { aiEgressPolicyFor } from "./local-host-allowlist";
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import {
|
||||
buildOpenAIMessages,
|
||||
buildOpenAITools,
|
||||
@@ -62,6 +63,11 @@ export class OpenAIClient implements AIProvider {
|
||||
readonly type: OpenAIProviderType;
|
||||
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
|
||||
responseTimeoutSeconds: number | null = null;
|
||||
/**
|
||||
* See `AIProvider.reasoningEffort`; stamped by the resolver. Only the
|
||||
* gateway tag ever sends it (`isGateway` below), whatever is stamped here.
|
||||
*/
|
||||
reasoningEffort: ReasoningEffort | null = null;
|
||||
private config: OpenAIClientConfig;
|
||||
|
||||
constructor(config: OpenAIClientConfig) {
|
||||
@@ -150,6 +156,11 @@ export class OpenAIClient implements AIProvider {
|
||||
: {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
|
||||
// #1126 — the gateway entry's reasoning setting; Default sends
|
||||
// nothing. `api.openai.com` and Codex never get it.
|
||||
...(this.isGateway && this.reasoningEffort
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
// 60 s ceiling so a tar-pit upstream cannot pin a worker
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
* `PROVIDER_CHAIN_DEFAULT`.
|
||||
*/
|
||||
|
||||
import {
|
||||
isReasoningEffort,
|
||||
isReasoningProviderType,
|
||||
type ReasoningEffort,
|
||||
type ReasoningProviderType,
|
||||
} from "./reasoning-effort";
|
||||
|
||||
/**
|
||||
* Provider tags used in the chain. `admin-openai` is the legacy
|
||||
* `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users
|
||||
@@ -63,6 +70,8 @@ export interface ProviderChainEntry {
|
||||
providerType: ProviderChainType;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
/** Only ever set on a `REASONING_PROVIDER_TYPES` entry. Absent = Default. */
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +122,7 @@ export function parseProviderChain(
|
||||
providerType?: unknown;
|
||||
priority?: unknown;
|
||||
enabled?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
};
|
||||
const providerType = candidate.providerType;
|
||||
if (typeof providerType !== "string") continue;
|
||||
@@ -128,10 +138,18 @@ export function parseProviderChain(
|
||||
: valid.length + 1;
|
||||
const enabled =
|
||||
typeof candidate.enabled === "boolean" ? candidate.enabled : true;
|
||||
// A reasoning effort survives only on an entry that may carry one and
|
||||
// only with a known value; anything else reads as Default.
|
||||
const reasoningEffort =
|
||||
isReasoningProviderType(providerType) &&
|
||||
isReasoningEffort(candidate.reasoningEffort)
|
||||
? candidate.reasoningEffort
|
||||
: undefined;
|
||||
valid.push({
|
||||
providerType: providerType as ProviderChainType,
|
||||
priority,
|
||||
enabled,
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +171,39 @@ export function serializeProviderChain(
|
||||
providerType: e.providerType,
|
||||
priority: e.priority,
|
||||
enabled: e.enabled,
|
||||
...(e.reasoningEffort ? { reasoningEffort: e.reasoningEffort } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain to persist after setting (or, with `null`, clearing) one entry's
|
||||
* reasoning effort from that provider's own settings form.
|
||||
*
|
||||
* The setting lives on the chain entry, so a chain that was never customised
|
||||
* is materialised from the default first. A provider that is not in the chain
|
||||
* at all gets a DISABLED entry to hold the value: the setting must reach the
|
||||
* provider when it is resolved as the selected one, and a disabled entry is
|
||||
* inert everywhere else (every reader filters on `enabled`). Clearing on an
|
||||
* absent entry writes nothing new.
|
||||
*/
|
||||
export function chainWithReasoningEffort(
|
||||
rawChain: unknown,
|
||||
providerType: ReasoningProviderType,
|
||||
effort: ReasoningEffort | null,
|
||||
): ProviderChainEntry[] {
|
||||
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
|
||||
const entry = chain.find((e) => e.providerType === providerType);
|
||||
if (entry) {
|
||||
if (effort) entry.reasoningEffort = effort;
|
||||
else delete entry.reasoningEffort;
|
||||
} else if (effort) {
|
||||
chain.push({
|
||||
providerType,
|
||||
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
|
||||
enabled: false,
|
||||
reasoningEffort: effort,
|
||||
});
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
+33
-15
@@ -1,5 +1,6 @@
|
||||
import type { AIProvider, CompletionResult } from "./types";
|
||||
import { bindResponseTimeout } from "./effective-timeout";
|
||||
import { bindReasoningEffort } from "./reasoning-effort";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { CodexClient, resolveCodexVisionSlug } from "./codex-client";
|
||||
@@ -501,13 +502,19 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
role: true,
|
||||
managedProfileAt: true,
|
||||
aiResponseTimeoutSeconds: true,
|
||||
aiProviderChain: true,
|
||||
},
|
||||
});
|
||||
// The record's response-timeout setting rides on whatever this returns, on
|
||||
// every branch, the operator-key fallback for a managed profile included:
|
||||
// the person waiting on the record is who the call serves.
|
||||
// the person waiting on the record is who the call serves. The reasoning
|
||||
// effort (#1126) rides the same way; it only lands on a Local or gateway
|
||||
// instance, so the operator fallback never picks one up.
|
||||
const bind = (provider: AIProvider): AIProvider =>
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
|
||||
bindReasoningEffort(
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
|
||||
userRow?.aiProviderChain,
|
||||
);
|
||||
const policy = providerCredentialPolicy(
|
||||
authority,
|
||||
userRow?.managedProfileAt ?? null,
|
||||
@@ -533,18 +540,23 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The record owner's `aiResponseTimeoutSeconds`, for `resolveProviderForTest`,
|
||||
* whose many branches (saved config, unsaved override, the chain) are bound in
|
||||
* one place on the way out. The other two resolvers read it with their row.
|
||||
* The record owner's `aiResponseTimeoutSeconds` and `aiProviderChain`, for
|
||||
* `resolveProviderForTest`, whose many branches (saved config, unsaved
|
||||
* override, the chain) are bound in one place on the way out. The other two
|
||||
* resolvers read them with their row.
|
||||
*/
|
||||
async function readResponseTimeoutSeconds(
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
async function readBoundSettings(userId: string): Promise<{
|
||||
aiResponseTimeoutSeconds: number | null;
|
||||
aiProviderChain: unknown;
|
||||
}> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiResponseTimeoutSeconds: true },
|
||||
select: { aiResponseTimeoutSeconds: true, aiProviderChain: true },
|
||||
});
|
||||
return row?.aiResponseTimeoutSeconds ?? null;
|
||||
return {
|
||||
aiResponseTimeoutSeconds: row?.aiResponseTimeoutSeconds ?? null,
|
||||
aiProviderChain: row?.aiProviderChain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,9 +607,12 @@ export async function resolveProviderChain(
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
// See `resolveProvider`: every instance carries the record's setting.
|
||||
// See `resolveProvider`: every instance carries the record's settings.
|
||||
const bind = (provider: AIProvider): AIProvider =>
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
|
||||
bindReasoningEffort(
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
|
||||
userRow?.aiProviderChain,
|
||||
);
|
||||
const policy = providerCredentialPolicy(
|
||||
authority,
|
||||
userRow?.managedProfileAt ?? null,
|
||||
@@ -1184,10 +1199,13 @@ export async function resolveProviderForTest(
|
||||
): Promise<AIProvider> {
|
||||
// The test runs under the same timeout generation would, so a slow local
|
||||
// model that needs a raised setting is not reported as dead by the test.
|
||||
// The same holds for the entry's reasoning effort (#1126): a thinking model
|
||||
// that only answers with reasoning switched off must test green with it off.
|
||||
const provider = await resolveProviderForTestUnbound(userId, override);
|
||||
return bindResponseTimeout(
|
||||
provider,
|
||||
await readResponseTimeoutSeconds(userId),
|
||||
const settings = await readBoundSettings(userId);
|
||||
return bindReasoningEffort(
|
||||
bindResponseTimeout(provider, settings.aiResponseTimeoutSeconds),
|
||||
settings.aiProviderChain,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* #1126 — the per-entry reasoning setting of the Local and OpenAI-compatible
|
||||
* providers, and the binder that stamps it onto a resolved provider.
|
||||
*
|
||||
* A leaf on purpose: `types.ts` and the binder are reachable from workers that
|
||||
* must not reach the provider machinery (`data-arrival-provider-isolation`),
|
||||
* so nothing here imports `provider-chain.ts`. The stored entry is read from
|
||||
* the raw chain directly, first match wins, the same rule
|
||||
* `parseProviderChain` dedupes by.
|
||||
*/
|
||||
import type { AIProvider } from "./types";
|
||||
|
||||
/**
|
||||
* The `reasoning_effort` values a chain entry may carry. "Off" in the settings
|
||||
* is `none` on the wire. The absence of the key is "Default": nothing is sent
|
||||
* and the model decides, which is what every chain stored before the setting
|
||||
* existed keeps doing.
|
||||
*/
|
||||
export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
|
||||
|
||||
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
||||
|
||||
/**
|
||||
* The entries that may carry a reasoning effort: the two whose wire is a
|
||||
* person-chosen `/chat/completions` endpoint serving a model of their choice.
|
||||
* OpenAI, Anthropic and Codex pin their own model families and parameters,
|
||||
* so the setting is refused for them rather than silently dropped.
|
||||
*/
|
||||
export const REASONING_PROVIDER_TYPES = ["local", "openai-compatible"] as const;
|
||||
|
||||
export type ReasoningProviderType = (typeof REASONING_PROVIDER_TYPES)[number];
|
||||
|
||||
export function isReasoningProviderType(
|
||||
providerType: string,
|
||||
): providerType is ReasoningProviderType {
|
||||
return (REASONING_PROVIDER_TYPES as readonly string[]).includes(providerType);
|
||||
}
|
||||
|
||||
export function isReasoningEffort(value: unknown): value is ReasoningEffort {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(REASONING_EFFORTS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoning effort stored on a provider type's entry in a persisted chain,
|
||||
* enabled or not. `null` is Default: no chain, no entry, no valid value, or a
|
||||
* type that cannot carry one.
|
||||
*/
|
||||
export function reasoningEffortFor(
|
||||
rawChain: unknown,
|
||||
providerType: string,
|
||||
): ReasoningEffort | null {
|
||||
if (!isReasoningProviderType(providerType) || !Array.isArray(rawChain)) {
|
||||
return null;
|
||||
}
|
||||
const entry = rawChain.find(
|
||||
(item): item is { reasoningEffort?: unknown } =>
|
||||
item != null &&
|
||||
typeof item === "object" &&
|
||||
(item as { providerType?: unknown }).providerType === providerType,
|
||||
);
|
||||
return isReasoningEffort(entry?.reasoningEffort)
|
||||
? entry.reasoningEffort
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the record owner's reasoning effort onto a resolved provider. Keyed on
|
||||
* the instance's runtime type, so only a Local client or an OpenAI-compatible
|
||||
* gateway can ever carry one: the operator's provider, the personal OpenAI
|
||||
* key, Anthropic and Codex resolve to `null` whatever the stored chain says.
|
||||
* Called by every exported resolver in `provider.ts` next to
|
||||
* `bindResponseTimeout`, with the raw `User.aiProviderChain` value.
|
||||
*/
|
||||
export function bindReasoningEffort<P extends AIProvider>(
|
||||
provider: P,
|
||||
aiProviderChain: unknown,
|
||||
): P {
|
||||
provider.reasoningEffort = reasoningEffortFor(aiProviderChain, provider.type);
|
||||
return provider;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// ─── Insight Result Schema ─────────────────────────────────
|
||||
@@ -123,6 +124,13 @@ export interface AIProvider {
|
||||
* `null` / absent → the surface's `timeoutMs`, else the 60 s default.
|
||||
*/
|
||||
responseTimeoutSeconds?: number | null;
|
||||
/**
|
||||
* #1126 — the `reasoning_effort` the record owner chose for this provider's
|
||||
* chain entry, stamped by the resolver (`bindReasoningEffort`). Only the
|
||||
* Local client and the OpenAI-compatible gateway read it, and only they are
|
||||
* ever stamped with a value. `null` / absent → Default: the key is not sent.
|
||||
*/
|
||||
reasoningEffort?: ReasoningEffort | null;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
/**
|
||||
* v1.22 (#89) — optional true-streaming variant. A provider that can emit
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
|
||||
import { buildCycleBackupSection } from "../backup";
|
||||
|
||||
@@ -236,3 +239,87 @@ describe("a symptom the account created survives the round trip", () => {
|
||||
expect(section.customSymptoms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a portable file carries the cycle free text readable", () => {
|
||||
// Ciphertext in a portable file is noise on any host with another key, so
|
||||
// the note, the sensitive envelope and a custom label travel opened, and
|
||||
// the restore seals them under the receiving host's key.
|
||||
const savedKey = process.env.ENCRYPTION_KEY;
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEY = "ab".repeat(32);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = savedKey;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function client(dayLog: Record<string, unknown>, label: string | null) {
|
||||
return {
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ date: "2026-07-02", symptomLinks: [], ...dayLog },
|
||||
]),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: label,
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("opens each sealed value and carries no ciphertext", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: encrypt("Cramps at four"),
|
||||
sensitiveEncrypted: encrypt(
|
||||
JSON.stringify({ sexualActivity: true, contraceptive: "ORAL" }),
|
||||
),
|
||||
},
|
||||
encrypt("Back ache"),
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
const day = section.cycleDayLogs[0];
|
||||
expect(day.note).toBe("Cramps at four");
|
||||
expect(day.sensitive).toEqual({
|
||||
sexualActivity: true,
|
||||
contraceptive: "ORAL",
|
||||
});
|
||||
expect(day).not.toHaveProperty("notesEncrypted");
|
||||
expect(day).not.toHaveProperty("sensitiveEncrypted");
|
||||
expect(section.customSymptoms[0].label).toBe("Back ache");
|
||||
expect(section.customSymptoms[0]).not.toHaveProperty("labelEncrypted");
|
||||
});
|
||||
|
||||
it("writes the unreadable marker where this host cannot open a value", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: "v9.bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWF0LWFsbC1yZWFsbHk=",
|
||||
},
|
||||
null,
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
expect(section.cycleDayLogs[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
expect(section.cycleDayLogs[0].sensitive).toBeNull();
|
||||
expect(section.customSymptoms[0].label).toBeNull();
|
||||
});
|
||||
});
|
||||
+113
-22
@@ -5,10 +5,25 @@
|
||||
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
|
||||
*
|
||||
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
|
||||
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
|
||||
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
|
||||
* the day-log note, so a wrong-surface plaintext leak is impossible and the
|
||||
* owner's note round-trips encrypted at rest.
|
||||
* (forecast) cycle rows — only observed history round-trips.
|
||||
*
|
||||
* ## Free text follows the note contract
|
||||
*
|
||||
* Three columns are sealed: the day-log note, the sensitive-category envelope
|
||||
* and a custom symptom's label. A disaster-recovery file carries them as the
|
||||
* stored ciphertext, which the same instance's key reads back. A portable file
|
||||
* carries them readable (`note`, `sensitive`, `label`), and the restore seals
|
||||
* them under the receiving host's key, because a portable file exists to be
|
||||
* moved, and ciphertext from one host is noise on another.
|
||||
*
|
||||
* Until v1.40 the portable file carried the ciphertext too, so a note restored
|
||||
* onto a host with a different key was unreadable for good. Such a file is
|
||||
* still accepted: every ciphertext value in it is opened with this host's keys
|
||||
* before it is written, and one that does not open is kept out of the row and
|
||||
* named in the skip report (`cycleCiphertext`), never written back as
|
||||
* ciphertext nobody can read. The same check covers a disaster-recovery file,
|
||||
* which is why `RESTORE_SELF_VERIFIED_SECTIONS` lets these sections past the
|
||||
* restore's key preflight.
|
||||
*/
|
||||
import type {
|
||||
CervicalMucus,
|
||||
@@ -25,6 +40,12 @@ import type {
|
||||
PrismaClient,
|
||||
SecondarySymptom,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
openSealedForExport,
|
||||
sealForRestore,
|
||||
UNOPENED,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -91,8 +112,17 @@ export interface CycleBackupSection {
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
sensitiveEncrypted?: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
notesEncrypted?: string | null;
|
||||
/** Plaintext; present on a portable payload. */
|
||||
note?: string | null;
|
||||
/**
|
||||
* The sensitive-category envelope opened; present on a portable payload.
|
||||
* The unreadable marker when this instance could not open it.
|
||||
*/
|
||||
sensitive?: CycleSensitiveFields | string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
tz: string | null;
|
||||
@@ -142,18 +172,40 @@ export interface CycleBackupSection {
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* The user's own words for the symptom, carried as ciphertext verbatim.
|
||||
* The user's own words for the symptom: ciphertext on a disaster-recovery
|
||||
* payload, plaintext (`label`) on a portable one.
|
||||
*
|
||||
* The builder has always emitted it and this interface did not declare it,
|
||||
* which held while the payload was assembled key by key and stopped holding
|
||||
* the moment the section is spread onto the wire: an undeclared field is a
|
||||
* field the wire type says is not there. Declared, so the type describes
|
||||
* the file.
|
||||
* Declared rather than left to the spread, because an undeclared field is
|
||||
* a field the wire type says is not there.
|
||||
*/
|
||||
labelEncrypted: string | null;
|
||||
labelEncrypted?: string | null;
|
||||
label?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The five intent fields the sensitive-category envelope holds. */
|
||||
export interface CycleSensitiveFields {
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: string | null;
|
||||
progesteroneTest?: string | null;
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/** The sensitive envelope opened for a portable file, or the marker. */
|
||||
function openSensitiveForExport(
|
||||
sealed: string | null,
|
||||
): CycleSensitiveFields | string | null {
|
||||
if (!sealed) return null;
|
||||
const opened = openSealedForExport(sealed, "cycle sensitive envelope");
|
||||
if (opened === UNREADABLE_EXPORT_MARKER) return opened;
|
||||
try {
|
||||
return JSON.parse(opened) as CycleSensitiveFields;
|
||||
} catch {
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cycle section of a user's full backup. Accepts any client with
|
||||
* the cycle delegates (the route's global client OR the worker's local
|
||||
@@ -259,8 +311,17 @@ export async function buildCycleBackupSection(
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
}
|
||||
: {
|
||||
sensitive: openSensitiveForExport(d.sensitiveEncrypted),
|
||||
note: d.notesEncrypted
|
||||
? openSealedForExport(d.notesEncrypted, "cycle day-log note")
|
||||
: null,
|
||||
}),
|
||||
source: d.source,
|
||||
externalId: d.externalId,
|
||||
tz: d.tz,
|
||||
@@ -277,7 +338,13 @@ export async function buildCycleBackupSection(
|
||||
icon: sym.icon,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted,
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: sym.labelEncrypted }
|
||||
: {
|
||||
label: sym.labelEncrypted
|
||||
? openSealedForExport(sym.labelEncrypted, "cycle symptom label")
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -383,8 +450,9 @@ function enumOrNull<T extends string>(
|
||||
* inside an existing transaction. Delete-then-recreate, matching the
|
||||
* measurement/mood restore contract. Symptom links are re-resolved against
|
||||
* the seeded catalogue by key, and each one gets back the 1-4 intensity the
|
||||
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
|
||||
* written back as the ciphertext envelope verbatim — never re-encrypted.
|
||||
* file recorded for it, or none if the file recorded none. Sealed free text
|
||||
* comes back as the file-header comment describes: readable values sealed
|
||||
* under this host's key, ciphertext written back only when this host opens it.
|
||||
*
|
||||
* A key that resolves against nothing is dropped from the day's links and
|
||||
* recorded on `skips`. It is a required argument rather than an optional one:
|
||||
@@ -400,6 +468,9 @@ export async function restoreCycleData(
|
||||
payload: BackupPayload,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<CycleRestoreCleared> {
|
||||
// Sealed values from the file this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
// Wipe (child links cascade off the day-log delete).
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
@@ -488,7 +559,12 @@ export async function restoreCycleData(
|
||||
icon: sym.icon ?? null,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted ?? null,
|
||||
labelEncrypted: sealForRestore(
|
||||
sym.labelEncrypted,
|
||||
sym.label,
|
||||
`customSymptoms.${sym.key}.labelEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide. Leave the
|
||||
// catalogue row alone: the account's links resolve against it either way.
|
||||
@@ -591,9 +667,22 @@ export async function restoreCycleData(
|
||||
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS),
|
||||
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS),
|
||||
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES),
|
||||
// Both ciphertext envelopes written back verbatim — never re-encrypted.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted ?? null,
|
||||
notesEncrypted: d.notesEncrypted ?? null,
|
||||
sensitiveEncrypted: sealForRestore(
|
||||
d.sensitiveEncrypted,
|
||||
d.sensitive === undefined || d.sensitive === null
|
||||
? d.sensitive
|
||||
: typeof d.sensitive === "string"
|
||||
? UNOPENED
|
||||
: JSON.stringify(d.sensitive),
|
||||
`cycleDayLogs.${d.date}.sensitiveEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
notesEncrypted: sealForRestore(
|
||||
d.notesEncrypted,
|
||||
d.note,
|
||||
`cycleDayLogs.${d.date}.notesEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
|
||||
externalId: d.externalId ?? null,
|
||||
tz: d.tz ?? null,
|
||||
@@ -608,6 +697,8 @@ export async function restoreCycleData(
|
||||
});
|
||||
}
|
||||
|
||||
recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened);
|
||||
|
||||
return {
|
||||
cycles: cycles.count,
|
||||
cycleDayLogs: dayLogs.count,
|
||||
|
||||
@@ -115,6 +115,6 @@ describe("generateThumbnail behind a closed gate", () => {
|
||||
Buffer.from([0xff, 0xd8, 0xff]),
|
||||
"image/jpeg",
|
||||
);
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(result).toEqual({ ok: false, reason: "native-canvas-unsupported" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Refs #776 — the raster-failed chain, end to end through the REAL
|
||||
@@ -68,4 +70,17 @@ describe("raster-failed end to end (#776)", () => {
|
||||
const vision = await prepareVisionInput(DOC as never, true);
|
||||
expect(vision).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("a PDF whose pages all render blank is rasterFailed, not an image of nothing (#1124)", async () => {
|
||||
// Text in render mode 3 only: pdfjs opens it and draws an empty page.
|
||||
// Before the blank-page guard this went to the provider as one white
|
||||
// JPEG and came back as a near-empty transcript indexed as a success.
|
||||
vi.mocked(decryptDocumentContent).mockReturnValue(
|
||||
readFileSync(
|
||||
join(process.cwd(), "tests/fixtures/pdf/invisible-text-only.pdf"),
|
||||
) as never,
|
||||
);
|
||||
const vision = await prepareVisionInput(DOC as never, false);
|
||||
expect(vision).toEqual({ ok: false, reason: "rasterFailed" });
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,16 @@ vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
canvasFactory: {
|
||||
create: () => ({
|
||||
canvas: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
toBuffer: () => Buffer.from([0xff, 0xd8]),
|
||||
},
|
||||
context: {},
|
||||
// One black pixel: an inked page, so the blank-page guard passes.
|
||||
context: {
|
||||
getImageData: () => ({
|
||||
data: new Uint8ClampedArray([0, 0, 0, 255]),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
getPage: async () => ({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* GitHub #1124 — PDFs from older OCRmyPDF archives got no thumbnail and an
|
||||
* image-only vision read of them came back nearly empty.
|
||||
*
|
||||
* The runtime image has no system fonts. pdfjs was opened without its own
|
||||
* font data and told to lean on system fonts, so any text it could not draw
|
||||
* from an embedded program (a non-embedded standard font, or an embedded
|
||||
* program it cannot parse) came out as nothing, and the page that reached
|
||||
* the provider was the background alone. A workstation hides this because
|
||||
* its own fonts stand in, so the rendering assertions below are regression
|
||||
* guards here and the container is where they were proven red; the option
|
||||
* and registration assertions are red on any host.
|
||||
*
|
||||
* Fixtures (`tests/fixtures/pdf/`, synthetic text only):
|
||||
* - `ocrmypdf11-two-images-type1c.pdf` — the reporter's shape: PDF 1.7,
|
||||
* pikepdf producer, one 630.5 x 861.6 pt page with a 150 ppi RGB JPEG and
|
||||
* a 75 ppi RGB Flate image, Type 1C Helvetica and Palatino-Roman, and an
|
||||
* invisible OCR text layer.
|
||||
* - `fallback-fonts.pdf` — a letter in non-embedded Helvetica and in a
|
||||
* Type 1C Palatino-Roman whose program pdfjs cannot parse.
|
||||
* - `invisible-text-only.pdf` — text in render mode 3 and nothing else, so
|
||||
* every page renders blank.
|
||||
*/
|
||||
|
||||
const { annotate, registerFromPath } = vi.hoisted(() => ({
|
||||
annotate: vi.fn(),
|
||||
registerFromPath: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
|
||||
const getDocumentOptions: Record<string, unknown>[] = [];
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>();
|
||||
return {
|
||||
...actual,
|
||||
getDocument: (opts: Record<string, unknown>) => {
|
||||
getDocumentOptions.push(opts);
|
||||
return actual.getDocument(opts as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@napi-rs/canvas", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@napi-rs/canvas")>();
|
||||
return {
|
||||
...actual,
|
||||
GlobalFonts: {
|
||||
...actual.GlobalFonts,
|
||||
registerFromPath: (path: string, alias?: string) => {
|
||||
registerFromPath(path, alias);
|
||||
return actual.GlobalFonts.registerFromPath(path, alias);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as raster from "../rasterize-pdf";
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
|
||||
const FIXTURES = join(process.cwd(), "tests", "fixtures", "pdf");
|
||||
const fixture = (name: string) => readFileSync(join(FIXTURES, name));
|
||||
|
||||
/** Share of dark pixels in a JPEG, measured the way the rasterizer does. */
|
||||
async function inkOf(base64: string): Promise<number> {
|
||||
const image = await loadImage(Buffer.from(base64, "base64"));
|
||||
const canvas = createCanvas(image.width, image.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const { data } = ctx.getImageData(0, 0, image.width, image.height);
|
||||
let dark = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++;
|
||||
}
|
||||
return dark / (data.length / 4);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
annotate.mockClear();
|
||||
registerFromPath.mockClear();
|
||||
getDocumentOptions.length = 0;
|
||||
raster.__resetPdfjsAssetsForTests();
|
||||
});
|
||||
|
||||
describe("rasterizePdf font and decoder data (#1124)", () => {
|
||||
it("gives pdfjs its font, CMap and wasm directories and no system fonts", async () => {
|
||||
const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1);
|
||||
expect(result.ok).toBe(true);
|
||||
const opts = getDocumentOptions.at(-1)!;
|
||||
expect(opts.useSystemFonts).toBe(false);
|
||||
for (const [key, probe] of [
|
||||
["standardFontDataUrl", "FoxitSerif.pfb"],
|
||||
["standardFontDataUrl", "LiberationSans-Regular.ttf"],
|
||||
["cMapUrl", "UniJIS-UTF16-H.bcmap"],
|
||||
["wasmUrl", "openjpeg.wasm"],
|
||||
] as const) {
|
||||
expect(typeof opts[key], key).toBe("string");
|
||||
expect(existsSync(`${opts[key] as string}${probe}`), probe).toBe(true);
|
||||
}
|
||||
expect(opts.cMapPacked).toBe(true);
|
||||
});
|
||||
|
||||
it("registers a fallback face for every generic family pdfjs falls back to", async () => {
|
||||
await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1);
|
||||
const families = new Set(registerFromPath.mock.calls.map((c) => c[1]));
|
||||
expect(families).toEqual(new Set(["sans-serif", "serif", "monospace"]));
|
||||
for (const [path] of registerFromPath.mock.calls) {
|
||||
expect(existsSync(path as string)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the OCRmyPDF page with its scan and its text", async () => {
|
||||
const result = await raster.rasterizePdf(
|
||||
fixture("ocrmypdf11-two-images-type1c.pdf"),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.005);
|
||||
});
|
||||
|
||||
it("renders a letter whose fonts pdfjs has to substitute", async () => {
|
||||
const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf"));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.001);
|
||||
});
|
||||
|
||||
it("reports a document whose every page renders blank as render-failed", async () => {
|
||||
const result = await raster.rasterizePdf(
|
||||
fixture("invisible-text-only.pdf"),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "render-failed" });
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "documents.rasterize.failed" },
|
||||
meta: { reason: "blank_pages", pages: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateThumbnail on the OCRmyPDF shape (#1124)", () => {
|
||||
it("produces a preview of the first page", async () => {
|
||||
const result = await generateThumbnail(
|
||||
fixture("ocrmypdf11-two-images-type1c.pdf"),
|
||||
"application/pdf",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -126,19 +126,19 @@ describe("generateThumbnail", () => {
|
||||
it("returns { ok: false } for an unsupported MIME type", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("plain text"), "text/plain"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "unsupported-type" });
|
||||
});
|
||||
|
||||
it("returns { ok: false } and never throws on malformed image bytes", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a real image"), "image/png"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "error" });
|
||||
});
|
||||
|
||||
it("returns { ok: false } on a malformed PDF", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a pdf"), "application/pdf"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "raster-failed" });
|
||||
});
|
||||
|
||||
it("refuses a decompression bomb before decoding (pixel cap)", async () => {
|
||||
@@ -153,6 +153,7 @@ describe("generateThumbnail", () => {
|
||||
bomb.writeUInt32BE(30_000, 20); // height
|
||||
await expect(generateThumbnail(bomb, "image/png")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "pixel-cap",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +165,6 @@ describe("generateThumbnail", () => {
|
||||
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,9 @@
|
||||
* preserves the "a bad document never aborts an upload/batch" contract.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -60,6 +63,14 @@ export const RASTER_TARGET_LONG_EDGE = 1600;
|
||||
/** JPEG quality (0-100). ~80 keeps text legible at a fraction of PNG's bytes. */
|
||||
export const RASTER_JPEG_QUALITY = 80;
|
||||
|
||||
/**
|
||||
* A rendered page counts as blank when fewer than this share of its pixels
|
||||
* are dark. One short line of 16 pt text on an A4 page is ~0.1 %; a page
|
||||
* number alone sits near this floor. A page set that is blank throughout is
|
||||
* not a reading of the document, whatever the provider would make of it.
|
||||
*/
|
||||
export const RASTER_MIN_INK_RATIO = 0.0001;
|
||||
|
||||
/** One rasterized page, shaped as a vision `input_image` part. */
|
||||
export interface RasterImage {
|
||||
mediaType: "image/jpeg";
|
||||
@@ -98,9 +109,17 @@ interface PdfCanvas {
|
||||
height: number;
|
||||
toBuffer(mime: "image/jpeg", quality?: number): Buffer;
|
||||
}
|
||||
interface PdfContext2D {
|
||||
getImageData(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { data: Uint8ClampedArray };
|
||||
}
|
||||
interface PdfCanvasAndContext {
|
||||
canvas: PdfCanvas;
|
||||
context: unknown;
|
||||
context: PdfContext2D;
|
||||
}
|
||||
interface PdfCanvasFactory {
|
||||
create(width: number, height: number): PdfCanvasAndContext;
|
||||
@@ -125,12 +144,127 @@ interface PdfLoadingTask {
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
interface PdfjsModule {
|
||||
getDocument(opts: {
|
||||
data: Uint8Array;
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
}): PdfLoadingTask;
|
||||
getDocument(opts: PdfDocumentOptions): PdfLoadingTask;
|
||||
}
|
||||
interface PdfDocumentOptions {
|
||||
data: Uint8Array;
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
standardFontDataUrl?: string;
|
||||
cMapUrl?: string;
|
||||
cMapPacked?: boolean;
|
||||
wasmUrl?: string;
|
||||
}
|
||||
interface GlobalFontsLike {
|
||||
registerFromPath(path: string, nameAlias?: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where pdfjs finds the data it does not carry in its JS: the standard 14
|
||||
* fonts (a PDF that names Helvetica or Times without embedding them), the
|
||||
* CMaps (CID fonts with a predefined encoding), and the wasm image decoders
|
||||
* (JPEG 2000, JBIG2). In a browser pdfjs fetches these from a URL; under Node
|
||||
* they are read from disk, and without a directory pdfjs simply draws the text
|
||||
* without glyphs.
|
||||
*
|
||||
* The runtime image carries no system fonts at all, so nothing else catches
|
||||
* that: a letter set in a non-embedded font rendered as an empty page in the
|
||||
* container while it rendered correctly on a workstation, where the operating
|
||||
* system's fonts stood in. The image-only provider then read the empty page
|
||||
* and returned a line of nothing.
|
||||
*
|
||||
* Resolved from the working directory with Node's own resolution, which is
|
||||
* what finds the hoisted `node_modules/pdfjs-dist` in the standalone image
|
||||
* and the pnpm link in a checkout. The specifier is assembled at runtime so
|
||||
* the bundler leaves the lookup alone. The ICC profile option is not set: its
|
||||
* loader needs a synchronous XMLHttpRequest that Node lacks, and pdfjs falls
|
||||
* back to each colour space's alternate.
|
||||
*/
|
||||
export interface PdfjsAssets {
|
||||
standardFontDataUrl: string;
|
||||
cMapUrl: string;
|
||||
wasmUrl: string;
|
||||
/** The Liberation Sans faces registered as the generic fallback families. */
|
||||
fallbackFonts: string[];
|
||||
}
|
||||
|
||||
let cachedAssets: PdfjsAssets | null | undefined;
|
||||
|
||||
export function resolvePdfjsAssets(): PdfjsAssets | null {
|
||||
if (cachedAssets !== undefined) return cachedAssets;
|
||||
cachedAssets = null;
|
||||
try {
|
||||
const anchor = createRequire(join(process.cwd(), "package.json"));
|
||||
const root = dirname(
|
||||
anchor.resolve(["pdfjs-dist", "package.json"].join("/")),
|
||||
);
|
||||
const fonts = join(root, "standard_fonts");
|
||||
const assets: PdfjsAssets = {
|
||||
standardFontDataUrl: `${fonts}/`,
|
||||
cMapUrl: `${join(root, "cmaps")}/`,
|
||||
wasmUrl: `${join(root, "wasm")}/`,
|
||||
fallbackFonts: [
|
||||
"LiberationSans-Regular.ttf",
|
||||
"LiberationSans-Bold.ttf",
|
||||
"LiberationSans-Italic.ttf",
|
||||
"LiberationSans-BoldItalic.ttf",
|
||||
].map((file) => join(fonts, file)),
|
||||
};
|
||||
if (
|
||||
existsSync(join(fonts, "FoxitSerif.pfb")) &&
|
||||
existsSync(join(root, "cmaps", "UniJIS-UTF16-H.bcmap")) &&
|
||||
existsSync(join(root, "wasm", "openjpeg.wasm")) &&
|
||||
assets.fallbackFonts.every((file) => existsSync(file))
|
||||
) {
|
||||
cachedAssets = assets;
|
||||
}
|
||||
} catch {
|
||||
// Unresolvable: render without the data, as before, and say so below.
|
||||
}
|
||||
return cachedAssets;
|
||||
}
|
||||
|
||||
let fallbackFamiliesRegistered = false;
|
||||
|
||||
/**
|
||||
* Give the canvas a face for the generic families pdfjs falls back to.
|
||||
*
|
||||
* When pdfjs cannot use a font program (a damaged or unsupported embedded
|
||||
* font, or a non-embedded face outside the standard 14) it draws the text
|
||||
* with the canvas' own `fillText` under the PDF's font name and a generic
|
||||
* family. With no system fonts in the image that text vanished. Registering
|
||||
* Liberation Sans, which pdfjs ships, under `sans-serif`, `serif` and
|
||||
* `monospace` makes it legible instead. Once per process.
|
||||
*/
|
||||
async function registerFallbackFamilies(assets: PdfjsAssets): Promise<void> {
|
||||
if (fallbackFamiliesRegistered) return;
|
||||
fallbackFamiliesRegistered = true;
|
||||
const { GlobalFonts } = (await import("@napi-rs/canvas")) as unknown as {
|
||||
GlobalFonts: GlobalFontsLike;
|
||||
};
|
||||
for (const family of ["sans-serif", "serif", "monospace"]) {
|
||||
for (const file of assets.fallbackFonts) {
|
||||
GlobalFonts.registerFromPath(file, family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Share of dark pixels on a rendered page (luminance below half). */
|
||||
function inkRatio(context: PdfContext2D, width: number, height: number) {
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
let dark = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++;
|
||||
}
|
||||
const pixels = data.length / 4;
|
||||
return pixels > 0 ? dark / pixels : 0;
|
||||
}
|
||||
|
||||
/** Test hook: the asset lookup and the font registration are per process. */
|
||||
export function __resetPdfjsAssetsForTests(): void {
|
||||
cachedAssets = undefined;
|
||||
fallbackFamiliesRegistered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,11 +297,31 @@ export async function rasterizePdf(
|
||||
const pdfjs =
|
||||
(await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule;
|
||||
|
||||
const assets = resolvePdfjsAssets();
|
||||
if (assets) {
|
||||
await registerFallbackFamilies(assets);
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.assetsMissing" },
|
||||
meta: { reason: "pdfjs_assets_unresolved" },
|
||||
});
|
||||
}
|
||||
|
||||
task = pdfjs.getDocument({
|
||||
data: new Uint8Array(buffer),
|
||||
verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs.
|
||||
isEvalSupported: false, // never eval font programs (defence in depth).
|
||||
useSystemFonts: true,
|
||||
// There are no system fonts to use: substitutes come from pdfjs' own
|
||||
// standard font data and the fallback families registered above.
|
||||
useSystemFonts: false,
|
||||
...(assets
|
||||
? {
|
||||
standardFontDataUrl: assets.standardFontDataUrl,
|
||||
cMapUrl: assets.cMapUrl,
|
||||
cMapPacked: true,
|
||||
wasmUrl: assets.wasmUrl,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
doc = await task.promise;
|
||||
|
||||
@@ -175,6 +329,7 @@ export async function rasterizePdf(
|
||||
const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES);
|
||||
const pageCount = Math.min(doc.numPages, cap);
|
||||
const images: RasterImage[] = [];
|
||||
let blankPages = 0;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
const page = await doc.getPage(pageNumber);
|
||||
@@ -196,6 +351,12 @@ export async function rasterizePdf(
|
||||
viewport,
|
||||
canvas: cc.canvas,
|
||||
}).promise;
|
||||
if (
|
||||
inkRatio(cc.context, cc.canvas.width, cc.canvas.height) <
|
||||
RASTER_MIN_INK_RATIO
|
||||
) {
|
||||
blankPages++;
|
||||
}
|
||||
const jpeg = cc.canvas.toBuffer("image/jpeg", RASTER_JPEG_QUALITY);
|
||||
images.push({
|
||||
mediaType: "image/jpeg",
|
||||
@@ -217,9 +378,19 @@ export async function rasterizePdf(
|
||||
});
|
||||
return { ok: false, reason: "render-failed" };
|
||||
}
|
||||
if (blankPages === images.length) {
|
||||
// Every rendered page came out empty. Sending those to a vision model
|
||||
// buys a transcript of nothing that would be indexed as a success; say
|
||||
// the render failed instead, so the read reports `raster-failed`.
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.failed" },
|
||||
meta: { reason: "blank_pages", pages: images.length },
|
||||
});
|
||||
return { ok: false, reason: "render-failed" };
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.ok" },
|
||||
meta: { pages: images.length, cappedFrom: doc.numPages },
|
||||
meta: { pages: images.length, cappedFrom: doc.numPages, blankPages },
|
||||
});
|
||||
return { ok: true, images, pageCount: doc.numPages };
|
||||
} catch (err) {
|
||||
|
||||
@@ -157,9 +157,23 @@ export interface Thumbnail {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why no preview was made. `unsupported-type` is the expected answer for an
|
||||
* Office file or a text document; every other reason means a preview should
|
||||
* have existed, so the thumbnail job logs it as a warning.
|
||||
*/
|
||||
export type ThumbnailSkipReason =
|
||||
| "unsupported-type"
|
||||
| "native-canvas-unsupported"
|
||||
| "pixel-cap"
|
||||
| "raster-failed"
|
||||
| "empty-render"
|
||||
| "error";
|
||||
|
||||
/** Best-effort generation outcome — never an exception. */
|
||||
export type ThumbnailResult =
|
||||
{ ok: true; thumbnail: Thumbnail } | { ok: false };
|
||||
| { ok: true; thumbnail: Thumbnail }
|
||||
| { ok: false; reason: ThumbnailSkipReason };
|
||||
|
||||
// Minimal structural types for the slice of `@napi-rs/canvas` this module uses.
|
||||
// A type-only shape (erased at build) so we never eagerly evaluate the module
|
||||
@@ -229,7 +243,7 @@ export async function generateThumbnail(
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: { reason: "native_canvas_unsupported" },
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "native-canvas-unsupported" };
|
||||
}
|
||||
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
|
||||
// out of the eager server chunk. Resolved on first generation, then cached.
|
||||
@@ -244,7 +258,7 @@ export async function generateThumbnail(
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: { source: "image", reason: "pixel_cap", pixels },
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const image = await napi.loadImage(bytes);
|
||||
// Post-decode backstop: the header sniff returns null on any marker
|
||||
@@ -260,10 +274,10 @@ export async function generateThumbnail(
|
||||
pixels: image.width * image.height,
|
||||
},
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
if (!thumb) return { ok: false };
|
||||
if (!thumb) return { ok: false, reason: "empty-render" };
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "image", width: thumb.width, height: thumb.height },
|
||||
@@ -274,11 +288,13 @@ export async function generateThumbnail(
|
||||
if (mimeType === "application/pdf") {
|
||||
// Page 1 only — a preview never renders the whole document.
|
||||
const raster = await rasterizePdf(bytes, 1);
|
||||
if (!raster.ok || raster.images.length === 0) return { ok: false };
|
||||
if (!raster.ok || raster.images.length === 0) {
|
||||
return { ok: false, reason: "raster-failed" };
|
||||
}
|
||||
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
|
||||
const image = await napi.loadImage(pageJpeg);
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
if (!thumb) return { ok: false };
|
||||
if (!thumb) return { ok: false, reason: "empty-render" };
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "pdf", width: thumb.width, height: thumb.height },
|
||||
@@ -287,7 +303,7 @@ export async function generateThumbnail(
|
||||
}
|
||||
|
||||
// Every other MIME (Office/text/TIFF/HEIC/XML/JSON) — no preview.
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "unsupported-type" };
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.failed" },
|
||||
@@ -297,6 +313,6 @@ export async function generateThumbnail(
|
||||
message: err instanceof Error ? err.message.slice(0, 300) : String(err),
|
||||
},
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "error" };
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildAccountSettingsBackupSection,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
|
||||
|
||||
/** A 1x1 PNG, the smallest image the avatar upload accepts. */
|
||||
const PNG_1X1 =
|
||||
@@ -190,6 +191,38 @@ describe("admitAccountSettings", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #1126 — the reasoning setting lives on the provider chain entries, so
|
||||
// the chain column carries it through a backup unchanged.
|
||||
it("carries the provider chain's reasoning settings through a round trip", async () => {
|
||||
const chain = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith({ aiProviderChain: chain })),
|
||||
"u1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const restored = admitAccountSettings(
|
||||
JSON.parse(JSON.stringify(accountSettings)),
|
||||
ctx,
|
||||
).data.aiProviderChain;
|
||||
expect(restored).toEqual(chain);
|
||||
expect(reasoningEffortFor(restored, "local")).toBe("none");
|
||||
expect(reasoningEffortFor(restored, "openai-compatible")).toBe("high");
|
||||
});
|
||||
|
||||
it("writes a database null for a JSON setting the file clears", () => {
|
||||
expect(
|
||||
admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data
|
||||
|
||||
@@ -138,6 +138,30 @@ describe("assessBackupKeys", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the cycle sections to the restore, which opens each value itself", () => {
|
||||
// A same-id impostor in a cycle note must neither refuse the file nor be
|
||||
// picked as the sample that proves the key for the sections that need it.
|
||||
const fine = encrypt("a measurement note under the real key");
|
||||
useKeys({ old: K3, cur: K2 }, "old");
|
||||
const impostor = encrypt("x");
|
||||
useKeys({ old: K1, cur: K2 }, "cur");
|
||||
const file = {
|
||||
measurements: [{ notesEncrypted: fine }],
|
||||
cycleDayLogs: [
|
||||
{ notesEncrypted: impostor },
|
||||
{ notesEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
customSymptoms: [
|
||||
{ labelEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
};
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.visit(file);
|
||||
const verdict = assessBackupKeys(collector);
|
||||
expect(verdict.missing).toEqual([]);
|
||||
expect(verdict.unreadable).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches an id that is configured with different key material", () => {
|
||||
const file = fileWrittenUnderOld();
|
||||
useKeys({ old: K3, cur: K2 }, "cur");
|
||||
|
||||
@@ -97,6 +97,32 @@ export function isEncryptedMember(name: string): boolean {
|
||||
return name.endsWith("Encrypted") && name.length > "Encrypted".length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose restore opens every inner value with this host's keys itself,
|
||||
* and keeps out (and names in the skip report) any value it cannot open.
|
||||
*
|
||||
* The cycle day-logs, custom cycle symptoms, custom mood tags and mood
|
||||
* categories, practitioners, visits and vaccinations are here because a
|
||||
* portable file written before v1.40 carried their free text as the stored
|
||||
* ciphertext,
|
||||
* not as readable text. Such a file is still restorable onto another host:
|
||||
* what that host can open comes back, what it cannot stays out of the row and
|
||||
* is reported. Refusing the whole file over a note would cost every other
|
||||
* part of the account. The trade, stated: a disaster-recovery copy whose ONLY
|
||||
* missing key sits in these sections restores with those values reported as
|
||||
* skipped instead of being refused; any other section needing the key still
|
||||
* refuses it, which in practice is every real copy.
|
||||
*/
|
||||
export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet<string> = new Set([
|
||||
"cycleDayLogs",
|
||||
"customSymptoms",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
"practitioners",
|
||||
"encounters",
|
||||
"vaccinations",
|
||||
]);
|
||||
|
||||
interface KeyUse {
|
||||
/** How many values need this key. */
|
||||
count: number;
|
||||
@@ -127,7 +153,10 @@ export class BackupKeyIdCollector {
|
||||
}
|
||||
use.count += 1;
|
||||
use.sections.add(section);
|
||||
// A sample from a self-verified section proves nothing the restore needs:
|
||||
// a value there that does not open is skipped, not fatal.
|
||||
if (
|
||||
!RESTORE_SELF_VERIFIED_SECTIONS.has(section) &&
|
||||
value.length <= MAX_SAMPLE_CHARS &&
|
||||
(!use.sample || value.length < use.sample.value.length)
|
||||
) {
|
||||
@@ -257,7 +286,9 @@ function openSample(sample: { value: string; form: InnerCiphertextForm }) {
|
||||
* key to prove the key under that id is the right one.
|
||||
*
|
||||
* `ignoreSections` leaves out sections the caller will not write: the
|
||||
* instance settings, when the operator has not asked for them back.
|
||||
* instance settings, when the operator has not asked for them back. The
|
||||
* sections in {@link RESTORE_SELF_VERIFIED_SECTIONS} are always left out,
|
||||
* because their restore checks each value itself.
|
||||
*/
|
||||
export function assessBackupKeys(
|
||||
collector: BackupKeyIdCollector,
|
||||
@@ -274,7 +305,14 @@ export function assessBackupKeys(
|
||||
for (const [keyId, use] of [...collector.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if ([...use.sections].every((section) => ignore.has(section))) continue;
|
||||
if (
|
||||
[...use.sections].every(
|
||||
(section) =>
|
||||
ignore.has(section) || RESTORE_SELF_VERIFIED_SECTIONS.has(section),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
keyIds.push(keyId);
|
||||
configured ??= new Set(getConfiguredKeyIds());
|
||||
if (!configured.has(keyId)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { openSealedForExport } from "@/lib/export/sealed-text";
|
||||
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import {
|
||||
@@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload(
|
||||
icon: category.icon,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
// Ciphertext verbatim, like the tag label beside it.
|
||||
labelEncrypted: category.labelEncrypted,
|
||||
// The person's own words: ciphertext in a disaster-recovery payload,
|
||||
// readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: category.labelEncrypted }
|
||||
: {
|
||||
label: category.labelEncrypted
|
||||
? openSealedForExport(
|
||||
category.labelEncrypted,
|
||||
"mood category label",
|
||||
)
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
|
||||
key: hidden.moodTag.key,
|
||||
@@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload(
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon,
|
||||
sortOrder: tag.sortOrder,
|
||||
// Ciphertext verbatim, like every other *Encrypted column in a DR
|
||||
// payload: the same instance's key reads it back unchanged.
|
||||
labelEncrypted: tag.labelEncrypted,
|
||||
// Ciphertext in a disaster-recovery payload (the same instance's key
|
||||
// reads it back), readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: tag.labelEncrypted }
|
||||
: {
|
||||
label: tag.labelEncrypted
|
||||
? openSealedForExport(tag.labelEncrypted, "mood tag label")
|
||||
: null,
|
||||
}),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
|
||||
+125
-102
@@ -66,6 +66,7 @@ import {
|
||||
listTenantEdges,
|
||||
} from "@/lib/export/tenant-boundary";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { sealForRestore } from "@/lib/export/sealed-text";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
@@ -1514,6 +1515,130 @@ export async function restoreBackup(
|
||||
}
|
||||
|
||||
reportSection("mood");
|
||||
// The account's own categories, tags and hidden set are written
|
||||
// whether or not the file carries a mood entry: an account that set
|
||||
// up its mood vocabulary before logging a single entry has them too,
|
||||
// and they used to be written only inside the entries branch.
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
// Labels this host's keys do not open, by file path.
|
||||
const unopenedMoodLabels: string[] = [];
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: sealForRestore(
|
||||
category.labelEncrypted,
|
||||
category.label,
|
||||
`customMoodTagCategories.${category.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: sealForRestore(
|
||||
tag.labelEncrypted,
|
||||
tag.label,
|
||||
`customMoodTags.${tag.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodLabelCiphertext",
|
||||
unopenedMoodLabels,
|
||||
unopenedMoodLabels,
|
||||
);
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.moodEntries.length > 0) {
|
||||
const referencedFactorKeys = payload.moodEntries.flatMap((entry) =>
|
||||
entry.factors.map((factor) => factor.key),
|
||||
@@ -1523,108 +1648,6 @@ export async function restoreBackup(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: tag.labelEncrypted ?? null,
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
const factorRows =
|
||||
factorKeys.length === 0
|
||||
? []
|
||||
|
||||
@@ -152,6 +152,25 @@
|
||||
* real foreign key, so the POINTER is dropped and the occurrence restores
|
||||
* unlinked. A portable export omits soft-deleted episodes, which is the
|
||||
* ordinary way an occurrence ends up naming one the file does not carry.
|
||||
*
|
||||
* The sixteenth, `cycleCiphertext`, is not a reference at all. It names a
|
||||
* sealed cycle value (a day-log note, the sensitive-category envelope, a
|
||||
* custom symptom's label) the file carried as ciphertext this host's keys do
|
||||
* not open — a portable file written before v1.40 on a host with another key.
|
||||
* Writing it back would store a value no reader can open, so the FIELD is kept
|
||||
* out and the day or the symptom restores without it. Its key is the path in
|
||||
* the file (`cycleDayLogs.<date>.notesEncrypted`), never the value.
|
||||
*
|
||||
* The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood
|
||||
* tag or mood category the account created
|
||||
* (`customMoodTags.<key>.labelEncrypted`): the tag or category restores, under
|
||||
* its key, without the person's own name for it.
|
||||
*
|
||||
* The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`,
|
||||
* are the same again for the free text of the visits record (a practitioner's
|
||||
* note, address and phone; a visit's reason, outcome and body site) and of a
|
||||
* vaccination's note (`vaccinations.<id>.noteEncrypted`). The row restores
|
||||
* without the field.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
| "cycleSymptom"
|
||||
@@ -172,7 +191,11 @@ export type SkippedCatalogue =
|
||||
| "scheduleRevisionLink"
|
||||
| "checkupClosure"
|
||||
| "accountSetting"
|
||||
| "symptomEpisodeReference";
|
||||
| "symptomEpisodeReference"
|
||||
| "cycleCiphertext"
|
||||
| "moodLabelCiphertext"
|
||||
| "visitCiphertext"
|
||||
| "vaccinationCiphertext";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Both ends of a sealed free-text column in a backup section: the string
|
||||
* codec (`<keyId>.<base64>` in a text column — the cycle day-log note and
|
||||
* sensitive envelope, custom cycle symptom and mood labels) and the shared
|
||||
* bytes codec (the practitioner note, the visit's reason, outcome and body
|
||||
* site, the vaccination note).
|
||||
*
|
||||
* The split is the one every other section uses. A disaster-recovery file
|
||||
* carries the stored ciphertext, which the same instance's key reads back. A
|
||||
* portable file carries the readable value (or the unreadable marker when
|
||||
* the writing host could not open it), and the restore seals it under the
|
||||
* receiving host's active key, because ciphertext from one host is noise on
|
||||
* another.
|
||||
*
|
||||
* Portable files written before v1.40 carried these columns as ciphertext.
|
||||
* They still restore: a ciphertext value is written back only when this
|
||||
* host's keys open it. One that does not open is kept out of the row and its
|
||||
* file path is recorded, so the caller can name it in the skip report rather
|
||||
* than store a value nobody can read. That per-value check is why the key
|
||||
* preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Open a sealed value for a portable file, or the unreadable marker. */
|
||||
export function openSealedForExport(sealed: string, field: string): string {
|
||||
try {
|
||||
return decrypt(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** A readable value the file could not supply: the writer could not open it. */
|
||||
export const UNOPENED: unique symbol = Symbol("unopened");
|
||||
|
||||
/**
|
||||
* The sealed column value for one free-text field from the file.
|
||||
*
|
||||
* Ciphertext is written back only when this host opens it — a value sealed
|
||||
* under a key this host does not hold (or the same key id over other key
|
||||
* material) would be a row nobody can read. A readable value is sealed under
|
||||
* this host's active key. Anything kept out is recorded under `path`.
|
||||
*/
|
||||
export function sealForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined | typeof UNOPENED,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): string | null {
|
||||
if (sealed) {
|
||||
try {
|
||||
decrypt(sealed);
|
||||
return sealed;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (readable === UNOPENED) {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
return readable ? encrypt(readable) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link openSealedForExport}, for columns written with
|
||||
* the shared bytes codec (`encryptToBytes`): the practitioner note, the
|
||||
* visit's reason, outcome and body site, the vaccination note.
|
||||
*/
|
||||
export function openSealedBytesForExport(
|
||||
sealed: Uint8Array | null,
|
||||
field: string,
|
||||
): string | null {
|
||||
if (!sealed || sealed.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */
|
||||
export function encodeSealedBytes(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a
|
||||
* disaster-recovery (or pre-v1.40 portable) file carries; it is written back
|
||||
* only when this host opens it. A readable value is sealed under this host's
|
||||
* active key.
|
||||
*/
|
||||
export function sealBytesForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (sealed) {
|
||||
const bytes = decodeBase64(sealed);
|
||||
try {
|
||||
decryptFromBytes(bytes);
|
||||
return bytes;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return readable ? encryptToBytes(readable) : null;
|
||||
}
|
||||
@@ -35,7 +35,11 @@
|
||||
* guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`
|
||||
* holds.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { VaccinationSite } from "@/generated/prisma/client";
|
||||
|
||||
@@ -72,8 +76,10 @@ export interface VaccinationBackupEntry {
|
||||
* lacks the reminder.
|
||||
*/
|
||||
reminderId: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts {
|
||||
vaccinationLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model — a structural matcher binds a model
|
||||
* to the literal beside its delegate call, and an inline object inside a
|
||||
@@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection(
|
||||
practitionerId: row.practitionerId,
|
||||
encounterId: row.encounterId,
|
||||
reminderId: row.reminderId,
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"vaccination note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -240,16 +247,10 @@ export type RestoredVaccination = Pick<
|
||||
| "encounterId"
|
||||
| "reminderId"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's immunization log and its document links.
|
||||
*
|
||||
@@ -307,6 +308,8 @@ export async function restoreVaccinationsData(
|
||||
const droppedPractitioners: string[] = [];
|
||||
const droppedEncounters: string[] = [];
|
||||
const droppedReminders: string[] = [];
|
||||
// Sealed notes this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.vaccinations.length > 0) {
|
||||
await tx.vaccinationRecord.createMany({
|
||||
@@ -349,10 +352,12 @@ export async function restoreVaccinationsData(
|
||||
practitionerId,
|
||||
encounterId,
|
||||
reminderId,
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`vaccinations.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -379,6 +384,7 @@ export async function restoreVaccinationsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened);
|
||||
|
||||
const restoredVaccinations = new Set(
|
||||
payload.vaccinations.map((entry) => entry.id),
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
* result ids, so a lab link in one cannot resolve; a disaster-recovery
|
||||
* payload carries every id and they all do.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type {
|
||||
EncounterKind,
|
||||
@@ -42,6 +41,11 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { readPractitionerContact } from "@/lib/practitioners/dto";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -71,8 +75,10 @@ export interface PractitionerBackupEntry {
|
||||
locationEncrypted?: string | null;
|
||||
/** Base64 ciphertext of the phone number, disaster-recovery payloads only. */
|
||||
phoneEncrypted?: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -87,10 +93,15 @@ export interface EncounterBackupEntry {
|
||||
status: EncounterStatus;
|
||||
kind: EncounterKind;
|
||||
practitionerId: string | null;
|
||||
reasonEncrypted: string | null;
|
||||
outcomeEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
reasonEncrypted?: string | null;
|
||||
outcomeEncrypted?: string | null;
|
||||
/** The procedure's body site, ciphertext like the two above (v1.39.1). */
|
||||
bodySiteEncrypted: string | null;
|
||||
bodySiteEncrypted?: string | null;
|
||||
/** The three readable; portable payloads only. */
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
bodySite?: string | null;
|
||||
laterality: Laterality | null;
|
||||
/**
|
||||
* The appointment's reminder row, which restores in the same run since
|
||||
@@ -124,12 +135,6 @@ export interface VisitsBackupCounts {
|
||||
encounterLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model.
|
||||
*
|
||||
@@ -256,10 +261,10 @@ export async function buildVisitsBackupSection(
|
||||
? {
|
||||
location: null,
|
||||
phone: null,
|
||||
locationEncrypted: encodeCiphertext(
|
||||
locationEncrypted: encodeSealedBytes(
|
||||
row.locationEncrypted ?? encryptNote(row.location),
|
||||
),
|
||||
phoneEncrypted: encodeCiphertext(
|
||||
phoneEncrypted: encodeSealedBytes(
|
||||
row.phoneEncrypted ?? encryptNote(row.phone),
|
||||
),
|
||||
}
|
||||
@@ -275,7 +280,14 @@ export async function buildVisitsBackupSection(
|
||||
"phone",
|
||||
),
|
||||
}),
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"practitioner note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -288,9 +300,26 @@ export async function buildVisitsBackupSection(
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitionerId: row.practitionerId,
|
||||
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted),
|
||||
...(disasterRecovery
|
||||
? {
|
||||
reasonEncrypted: encodeSealedBytes(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted),
|
||||
}
|
||||
: {
|
||||
reason: openSealedBytesForExport(
|
||||
row.reasonEncrypted,
|
||||
"visit reason",
|
||||
),
|
||||
outcome: openSealedBytesForExport(
|
||||
row.outcomeEncrypted,
|
||||
"visit outcome",
|
||||
),
|
||||
bodySite: openSealedBytesForExport(
|
||||
row.bodySiteEncrypted,
|
||||
"visit body site",
|
||||
),
|
||||
}),
|
||||
laterality: row.laterality,
|
||||
reminderId: row.reminderId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick<
|
||||
| "locationEncrypted"
|
||||
| "phoneEncrypted"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
@@ -394,18 +424,14 @@ export type RestoredEncounter = Pick<
|
||||
| "reasonEncrypted"
|
||||
| "outcomeEncrypted"
|
||||
| "bodySiteEncrypted"
|
||||
| "reason"
|
||||
| "outcome"
|
||||
| "bodySite"
|
||||
| "laterality"
|
||||
| "reminderId"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's visits, address book and link rows.
|
||||
*
|
||||
@@ -440,6 +466,9 @@ export async function restoreVisitsData(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// Sealed values this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.practitioners.length > 0) {
|
||||
await tx.practitioner.createMany({
|
||||
data: payload.practitioners.map((entry) => ({
|
||||
@@ -451,18 +480,24 @@ export async function restoreVisitsData(
|
||||
// v1.39.4 — ciphertext wins when a disaster-recovery file carried it;
|
||||
// a portable file's (or an older file's) readable value is sealed
|
||||
// under this instance's key. The readable columns are never written.
|
||||
locationEncrypted:
|
||||
entry.locationEncrypted != null
|
||||
? decodeCiphertext(entry.locationEncrypted)
|
||||
: encryptNote(entry.location),
|
||||
phoneEncrypted:
|
||||
entry.phoneEncrypted != null
|
||||
? decodeCiphertext(entry.phoneEncrypted)
|
||||
: encryptNote(entry.phone),
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
locationEncrypted: sealBytesForRestore(
|
||||
entry.locationEncrypted,
|
||||
entry.location,
|
||||
`practitioners.${entry.id}.locationEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
phoneEncrypted: sealBytesForRestore(
|
||||
entry.phoneEncrypted,
|
||||
entry.phone,
|
||||
`practitioners.${entry.id}.phoneEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`practitioners.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -517,20 +552,26 @@ export async function restoreVisitsData(
|
||||
status: entry.status,
|
||||
kind: entry.kind,
|
||||
practitionerId,
|
||||
reasonEncrypted:
|
||||
entry.reasonEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.reasonEncrypted),
|
||||
outcomeEncrypted:
|
||||
entry.outcomeEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.outcomeEncrypted),
|
||||
reasonEncrypted: sealBytesForRestore(
|
||||
entry.reasonEncrypted,
|
||||
entry.reason,
|
||||
`encounters.${entry.id}.reasonEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
outcomeEncrypted: sealBytesForRestore(
|
||||
entry.outcomeEncrypted,
|
||||
entry.outcome,
|
||||
`encounters.${entry.id}.outcomeEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
// Absent in a file written before v1.39.1: the visit comes back
|
||||
// with no site, which is what it had.
|
||||
bodySiteEncrypted:
|
||||
entry.bodySiteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.bodySiteEncrypted),
|
||||
bodySiteEncrypted: sealBytesForRestore(
|
||||
entry.bodySiteEncrypted,
|
||||
entry.bodySite,
|
||||
`encounters.${entry.id}.bodySiteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
laterality: entry.laterality ?? null,
|
||||
reminderId,
|
||||
createdAt: new Date(entry.createdAt),
|
||||
@@ -553,6 +594,7 @@ export async function restoreVisitsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "visitCiphertext", unopened, unopened);
|
||||
|
||||
const restoredEncounters = new Set(payload.encounters.map((e) => e.id));
|
||||
|
||||
|
||||
+216
-1
@@ -11,6 +11,19 @@
|
||||
* the record the request claims is folded into `key` (see `cellKey`), so the
|
||||
* tuple identifies a request to one person's record rather than a request by
|
||||
* one person. A caller acting only as themselves is unaffected, byte for byte.
|
||||
*
|
||||
* A cell also remembers WHICH request it answered: a SHA-256 of the canonical
|
||||
* request body (`requestFingerprint`). A key names one request, not a slot, and
|
||||
* a client that reuses a key for a second, different request must not be
|
||||
* handed the first request's response. Native clients up to 1.1.0 sent the two
|
||||
* halves of a manual blood pressure as two POSTs under one key; the second half
|
||||
* replayed the first's 201 and was never written. A lookup therefore replays,
|
||||
* or reports in-flight, only for the same body. A different body — or a row
|
||||
* written before fingerprints existed — runs the handler normally without
|
||||
* claiming or caching anything, and the existing cell stays for its own
|
||||
* retries. This is deliberately not the IETF draft's 422: refusing would turn
|
||||
* every affected client's second write into a visible error with no way for
|
||||
* the person to recover the value.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
@@ -109,6 +122,155 @@ export interface IdempotencyContext {
|
||||
key: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** See `requestFingerprint`. Never the body itself. */
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a body the fingerprint reads before it stops. The wrapper runs
|
||||
* before the handler's own bounded readers and upload slots, so it must not
|
||||
* buffer an arbitrarily large body on their behalf: reading the clone holds
|
||||
* what it has read in the original's queue until the handler consumes it.
|
||||
* Every JSON write the cache fronts is far below this; the one multipart route
|
||||
* (document upload) is above it, and there the prefix plus the declared length
|
||||
* identifies a retry of the same upload, with the route's own content hash as
|
||||
* the duplicate check behind it.
|
||||
*/
|
||||
const FINGERPRINT_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* JSON with every object's keys sorted, recursively. Arrays keep their order —
|
||||
* it is part of the request. Exported for its unit tests.
|
||||
*/
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(k) =>
|
||||
`${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`,
|
||||
);
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every occurrence of the multipart boundary named in `contentType`
|
||||
* with a fixed token. Any other body is returned as it is.
|
||||
*/
|
||||
function withoutMultipartBoundary(
|
||||
body: Uint8Array,
|
||||
contentType: string | null,
|
||||
): Uint8Array {
|
||||
if (!contentType || !/^multipart\//i.test(contentType)) return body;
|
||||
const match = /;\s*boundary="?([^";]+)"?/i.exec(contentType);
|
||||
if (!match) return body;
|
||||
const boundary = Buffer.from(match[1], "utf8");
|
||||
const token = Buffer.from("boundary", "utf8");
|
||||
const source = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
const parts: Buffer[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = source.indexOf(boundary, from);
|
||||
if (at === -1) break;
|
||||
parts.push(source.subarray(from, at), token);
|
||||
from = at + boundary.length;
|
||||
}
|
||||
if (parts.length === 0) return body;
|
||||
parts.push(source.subarray(from));
|
||||
return new Uint8Array(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprint of a request: SHA-256 hex over method, path and the body in
|
||||
* canonical form. A body that parses as JSON is hashed as `canonicalJson`, so
|
||||
* the same object serialised with a different key order is the same request;
|
||||
* anything else (multipart, plain text, an empty body) is hashed as its raw
|
||||
* bytes. `truncatedAt` marks a body cut at `FINGERPRINT_MAX_BYTES`, hashed as
|
||||
* that raw prefix together with the declared length. A multipart body has its
|
||||
* boundary replaced by a fixed token first: a client that rebuilds the form
|
||||
* for a retry picks a fresh random boundary, and the same upload must still
|
||||
* replay. Exported for unit tests.
|
||||
*/
|
||||
export function requestFingerprint(
|
||||
method: string,
|
||||
path: string,
|
||||
rawBody: Uint8Array,
|
||||
truncatedAt: string | null = null,
|
||||
contentType: string | null = null,
|
||||
): string {
|
||||
const body = withoutMultipartBoundary(rawBody, contentType);
|
||||
const hash = createHash("sha256")
|
||||
.update(method)
|
||||
.update("\0")
|
||||
.update(path)
|
||||
.update("\0");
|
||||
if (truncatedAt !== null) {
|
||||
return hash.update(`partial:${truncatedAt}\0`).update(body).digest("hex");
|
||||
}
|
||||
if (body.length > 0) {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
||||
return hash
|
||||
.update("json:")
|
||||
.update(canonicalJson(JSON.parse(text)))
|
||||
.digest("hex");
|
||||
} catch {
|
||||
// Not UTF-8 or not JSON — fall through to the raw bytes.
|
||||
}
|
||||
}
|
||||
return hash.update("raw:").update(body).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the request's body from a clone, at most `FINGERPRINT_MAX_BYTES`, and
|
||||
* fingerprint it. The original request keeps its body for the handler.
|
||||
*/
|
||||
async function fingerprintRequest(
|
||||
request: Request | NextRequest,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const stream = request.clone().body;
|
||||
if (!stream)
|
||||
return requestFingerprint(request.method, path, new Uint8Array());
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (total + value.byteLength > FINGERPRINT_MAX_BYTES) {
|
||||
chunks.push(value.subarray(0, FINGERPRINT_MAX_BYTES - total));
|
||||
total = FINGERPRINT_MAX_BYTES;
|
||||
truncated = true;
|
||||
// Cancelling this branch of the tee stops it from queueing the rest of
|
||||
// the body, and leaves the handler's branch intact. Not awaited: a tee
|
||||
// branch's cancel settles only once BOTH branches are done, which is
|
||||
// after the handler has run.
|
||||
void reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return requestFingerprint(
|
||||
request.method,
|
||||
path,
|
||||
body,
|
||||
truncated ? (request.headers.get("content-length") ?? "unknown") : null,
|
||||
request.headers.get("content-type"),
|
||||
);
|
||||
}
|
||||
|
||||
function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
@@ -125,10 +287,16 @@ function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
* - `{ kind: "pending" }` — another request holds an in-flight claim
|
||||
* for this key; the caller must NOT run the
|
||||
* handler again.
|
||||
* - `{ kind: "mismatch" }` — the live row answered a different body (or
|
||||
* predates fingerprints); the caller runs the
|
||||
* handler without claiming or caching.
|
||||
* - `null` — no live row; the caller may claim + run.
|
||||
*/
|
||||
type CacheLookup =
|
||||
{ kind: "replay"; response: NextResponse } | { kind: "pending" } | null;
|
||||
| { kind: "replay"; response: NextResponse }
|
||||
| { kind: "pending" }
|
||||
| { kind: "mismatch" }
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Look up the state of a (userId, key, method, path) tuple. Distinguishes
|
||||
@@ -157,6 +325,13 @@ async function findCached(ctx: IdempotencyContext): Promise<CacheLookup> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checked before both the pending and the replay arm: a different request
|
||||
// under the same key is neither in flight nor answered. A legacy row's null
|
||||
// never equals a fingerprint.
|
||||
if (row.requestFingerprint !== ctx.fingerprint) {
|
||||
return { kind: "mismatch" };
|
||||
}
|
||||
|
||||
if (row.responseStatus === PENDING_STATUS) {
|
||||
// A concurrent request claimed this key and is still running its
|
||||
// side-effect. Signal the caller to refuse rather than double-execute.
|
||||
@@ -208,6 +383,7 @@ async function claimKey(ctx: IdempotencyContext): Promise<boolean> {
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
responseStatus: PENDING_STATUS,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + PENDING_TTL_MS),
|
||||
@@ -233,6 +409,7 @@ async function releaseClaim(ctx: IdempotencyContext): Promise<void> {
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
responseStatus: PENDING_STATUS,
|
||||
},
|
||||
})
|
||||
@@ -312,6 +489,7 @@ async function persistCached(
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
},
|
||||
data: {
|
||||
responseStatus: response.status,
|
||||
@@ -502,6 +680,23 @@ async function narrowTokenFacet(): Promise<string | null> {
|
||||
return apiToken.permissions.includes("*") ? null : `t:${apiToken.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request whose key is already taken by a different body: the handler
|
||||
* runs exactly as it would without a key, and nothing is claimed or cached, so
|
||||
* the existing cell keeps answering its own retries.
|
||||
*/
|
||||
function runUncached<Args extends [Request | NextRequest, ...unknown[]]>(
|
||||
ctx: IdempotencyContext,
|
||||
handler: (...args: Args) => Promise<Response>,
|
||||
args: Args,
|
||||
): Promise<Response> {
|
||||
annotate({
|
||||
action: { name: "idempotency.key.body_mismatch" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return handler(...args);
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -589,14 +784,27 @@ export function withIdempotency<
|
||||
if (tokenFacet !== null) facets.push(tokenFacet);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const fingerprint = await fingerprintRequest(request, url.pathname).catch(
|
||||
() => null,
|
||||
);
|
||||
if (fingerprint === null) {
|
||||
// The body could not be read for the fingerprint (already consumed, or
|
||||
// the stream failed). Without knowing which request this is, no cell
|
||||
// may answer for it; the handler meets the body problem itself.
|
||||
return handler(...args);
|
||||
}
|
||||
const ctx: IdempotencyContext = {
|
||||
userId,
|
||||
key: cellKey(key, claimedRecord, facets),
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
fingerprint,
|
||||
};
|
||||
|
||||
const cached = await findCached(ctx);
|
||||
if (cached?.kind === "mismatch") {
|
||||
return runUncached(ctx, handler, args);
|
||||
}
|
||||
if (cached?.kind === "replay") {
|
||||
return cached.response;
|
||||
}
|
||||
@@ -617,6 +825,13 @@ export function withIdempotency<
|
||||
// one caller may execute the side-effect for a given key.
|
||||
const won = await claimKey(ctx);
|
||||
if (!won) {
|
||||
// The racing claim may be a different request under the same key —
|
||||
// the two halves of one reading posted together. That one is not a
|
||||
// duplicate of this one and must still run.
|
||||
const raced = await findCached(ctx);
|
||||
if (raced?.kind === "mismatch") {
|
||||
return runUncached(ctx, handler, args);
|
||||
}
|
||||
annotate({
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* GitHub #1124 — the thumbnail job ran 355 times on one instance, left
|
||||
* sixteen PDFs without a preview, and the log held not one line about it:
|
||||
* the job opened no wide event, so every annotation went nowhere. Pins: each
|
||||
* run is a `job.document_thumbnail` background event; a run that should have
|
||||
* made a preview and did not is raised to `warn` with the reason; a type that
|
||||
* never has a preview stays at info.
|
||||
*/
|
||||
|
||||
const { evt, findFirst, upsert, generateThumbnail, withBackgroundEvent } =
|
||||
vi.hoisted(() => {
|
||||
const evt = { addMeta: vi.fn(), elevateLevel: vi.fn() };
|
||||
return {
|
||||
evt,
|
||||
findFirst: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
generateThumbnail: vi.fn(),
|
||||
withBackgroundEvent: vi.fn(
|
||||
async (_name: string, fn: (e: typeof evt) => Promise<unknown>) =>
|
||||
fn(evt),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst },
|
||||
documentThumbnail: { upsert },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from("%PDF-1.7")),
|
||||
encryptThumbnail: vi.fn(() => new Uint8Array([1])),
|
||||
}));
|
||||
vi.mock("@/lib/documents/thumbnail", () => ({ generateThumbnail }));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent }));
|
||||
|
||||
import { runDocumentThumbnail } from "../document-thumbnail";
|
||||
|
||||
const DOC = {
|
||||
id: "doc-1",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: new Uint8Array([1]),
|
||||
contentCodec: "binary2",
|
||||
thumbnail: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findFirst.mockResolvedValue(DOC);
|
||||
});
|
||||
|
||||
describe("runDocumentThumbnail wide event", () => {
|
||||
it("runs inside a background event named for the job", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(withBackgroundEvent).toHaveBeenCalledWith(
|
||||
"job.document_thumbnail",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(evt.addMeta).toHaveBeenCalledWith("document_id", "doc-1");
|
||||
});
|
||||
|
||||
it("warns with the reason when a PDF gets no preview", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "error" });
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(evt.elevateLevel).toHaveBeenCalledWith("warn");
|
||||
expect(evt.addMeta).toHaveBeenCalledWith(
|
||||
"thumbnail_missing_reason",
|
||||
"error",
|
||||
);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays at info for a type that never has a preview", async () => {
|
||||
findFirst.mockResolvedValue({ ...DOC, mimeType: "text/plain" });
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "unsupported-type",
|
||||
});
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(evt.elevateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the preview and stays at info when one is made", async () => {
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: true,
|
||||
thumbnail: { jpeg: Buffer.from([0xff, 0xd8]), width: 2, height: 3 },
|
||||
});
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(upsert).toHaveBeenCalledOnce();
|
||||
expect(evt.elevateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@
|
||||
* kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not
|
||||
* retry it. Only an unexpected throw (a DB hiccup) propagates for retry.
|
||||
*
|
||||
* Each run is one background wide event (`job.document_thumbnail`). A run
|
||||
* that should have produced a preview and did not is raised to `warn` with
|
||||
* the reason, so a document left with its kind icon is explained in the log.
|
||||
* Without the event every annotation below went nowhere: a whole class of
|
||||
* PDFs went without previews for weeks and the log never said so.
|
||||
*
|
||||
* The queue MUST be registered in the maintenance registrar
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
||||
*/
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from "@/lib/documents/store";
|
||||
import { generateThumbnail } from "@/lib/documents/thumbnail";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail";
|
||||
@@ -49,7 +56,20 @@ export async function runDocumentThumbnail(
|
||||
): Promise<void> {
|
||||
const { userId, documentId } = payload;
|
||||
if (!userId || !documentId) return;
|
||||
await withBackgroundEvent("job.document_thumbnail", async (evt) => {
|
||||
evt.addMeta("document_id", documentId);
|
||||
await renderThumbnail(userId, documentId, (reason) => {
|
||||
evt.addMeta("thumbnail_missing_reason", reason);
|
||||
evt.elevateLevel("warn");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderThumbnail(
|
||||
userId: string,
|
||||
documentId: string,
|
||||
warn: (reason: string) => void,
|
||||
): Promise<void> {
|
||||
// Owner-scoped load; the blob column is selected ONLY here (the job that
|
||||
// renders the preview), never in the list/detail queries. Skip a document
|
||||
// that already has a thumbnail so a re-enqueue is a cheap no-op.
|
||||
@@ -84,6 +104,7 @@ export async function runDocumentThumbnail(
|
||||
action: { name: "documents.thumbnail.decryptFailed" },
|
||||
meta: { documentId },
|
||||
});
|
||||
warn("decrypt-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +112,11 @@ export async function runDocumentThumbnail(
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.run" },
|
||||
meta: { documentId, generated: false },
|
||||
meta: { documentId, generated: false, mimeType: document.mimeType },
|
||||
});
|
||||
// An Office or text document has no preview by design; anything else
|
||||
// was meant to get one.
|
||||
if (result.reason !== "unsupported-type") warn(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -323,57 +323,69 @@ function windowAsCourse(
|
||||
* Clearing both on a medication with one course deletes it (chronic again,
|
||||
* the pre-course meaning of no dates); on one with several it is refused,
|
||||
* because it would silently drop the history.
|
||||
*
|
||||
* `tx` runs the write inside the caller's transaction, so the PUT's row
|
||||
* update and this course write commit or roll back together: a refusal here
|
||||
* then leaves nothing of the PUT behind.
|
||||
*/
|
||||
export async function setCurrentWindow(
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
tx?: Tx,
|
||||
): Promise<ProjectedWindow> {
|
||||
return tx
|
||||
? writeCurrentWindow(tx, args)
|
||||
: prisma.$transaction((own) => writeCurrentWindow(own, args));
|
||||
}
|
||||
|
||||
async function writeCurrentWindow(
|
||||
tx: Tx,
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
): Promise<ProjectedWindow> {
|
||||
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
});
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SPARKLINE_MAX_POINTS,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
|
||||
import { providerChainPutSchema } from "@/lib/validations/ai-provider";
|
||||
import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative";
|
||||
import { aiCapabilityState } from "../profile";
|
||||
|
||||
@@ -2531,36 +2531,11 @@ export const providerChainResponse = z
|
||||
"The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire.",
|
||||
});
|
||||
|
||||
export const providerChainPutRequest = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(
|
||||
z.object({
|
||||
providerType: z
|
||||
.enum(PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]])
|
||||
.describe(
|
||||
"Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.",
|
||||
),
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
"ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe(
|
||||
"The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProviderChainPutRequest",
|
||||
description:
|
||||
"Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, and which are enabled.",
|
||||
});
|
||||
export const providerChainPutRequest = providerChainPutSchema.meta({
|
||||
id: "ProviderChainPutRequest",
|
||||
description:
|
||||
"Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, which are enabled, and the reasoning setting of the Local and OpenAI-compatible entries.",
|
||||
});
|
||||
|
||||
// ── Read-only advisor + generation (`/api/insights/generate`) ─────────
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/lib/validations/user-prefs";
|
||||
import {
|
||||
aiProviderPatchSchema,
|
||||
reasoningEffortSchema,
|
||||
aiTestOverrideSchema,
|
||||
} from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
@@ -674,6 +675,12 @@ const aiProviderResponse = z
|
||||
.describe(
|
||||
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
|
||||
),
|
||||
localReasoningEffort: reasoningEffortSchema.describe(
|
||||
"#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null = Default: the key is not sent.",
|
||||
),
|
||||
compatReasoningEffort: reasoningEffortSchema.describe(
|
||||
"#1126 — the same for the OpenAI-compatible gateway entry.",
|
||||
),
|
||||
// v1.38.19 — the setup flow's last screen used to promise
|
||||
// that insights "work for you right away" as soon as `managedBy` read
|
||||
// `server`. That is a presence read; it never knew whether the key
|
||||
|
||||
@@ -391,7 +391,8 @@ export const profileEmailRateLimitResponse = {
|
||||
*
|
||||
* Scope, so nobody reads more into it than is there: only POST, PUT, PATCH and
|
||||
* DELETE are covered (`SUPPORTED_METHODS`), the key is scoped per user and per
|
||||
* `(method, path)`, and `isCachableStatus` refuses to cache 5xx or the
|
||||
* `(method, path)`, a replay needs the same body (a different body under the
|
||||
* same key runs as a new write and is neither replayed nor refused), and `isCachableStatus` refuses to cache 5xx or the
|
||||
* transient 401 / 403 / 408 / 429 — a retry after one of those runs for real.
|
||||
*
|
||||
* `src/__tests__/idempotency-contract-publication.test.ts` holds this to the
|
||||
@@ -410,7 +411,7 @@ export const idempotencyKeyParameter = {
|
||||
maxLength: 128,
|
||||
},
|
||||
description:
|
||||
"Makes this write safe to retry. Send the same key again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second is refused with 409 rather than queued.",
|
||||
"Makes this write safe to retry. Send the same key with the same body again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as JSON with key order ignored; a different body under the same key is not a retry and runs as a new write, while the first response stays stored for retries of the first request. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second with the same body is refused with 409 rather than queued.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
|
||||
import {
|
||||
isReasoningProviderType,
|
||||
REASONING_EFFORTS,
|
||||
} from "@/lib/ai/reasoning-effort";
|
||||
|
||||
/** The five provider kinds a user may select. */
|
||||
export const AI_PROVIDER_KINDS = [
|
||||
"OPENAI",
|
||||
@@ -38,6 +44,17 @@ export const aiProviderKindSchema = z.enum(AI_PROVIDER_KINDS);
|
||||
*/
|
||||
const clearableString = z.string().nullable();
|
||||
|
||||
/**
|
||||
* #1126 — a provider entry's reasoning setting. `null` is "Default": nothing
|
||||
* is sent and the model decides. `none` is "Off".
|
||||
*/
|
||||
export const reasoningEffortSchema = z
|
||||
.enum(REASONING_EFFORTS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Reasoning setting for a Local or OpenAI-compatible provider, sent as `reasoning_effort` in the `/chat/completions` body. `none` switches reasoning off, which stops a thinking model (Gemma, Qwen and the like) spending the answer budget on reasoning. `null` is Default: the key is not sent at all and the model decides.",
|
||||
);
|
||||
|
||||
/**
|
||||
* `PATCH /api/user/ai-provider` — a partial update of the provider config.
|
||||
*
|
||||
@@ -93,6 +110,22 @@ export const aiProviderPatchSchema = z.object({
|
||||
.max(600, "Response timeout must be between 10 and 600 seconds")
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* #1126 — the reasoning setting of the Local entry. Stored on the entry in
|
||||
* the provider chain; a chain never customised is materialised from the
|
||||
* default to hold it. `null` restores Default.
|
||||
*/
|
||||
localReasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.",
|
||||
),
|
||||
/** #1126 — the same for the OpenAI-compatible gateway entry. */
|
||||
compatReasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.",
|
||||
),
|
||||
});
|
||||
|
||||
export type AiProviderPatchInput = z.infer<typeof aiProviderPatchSchema>;
|
||||
@@ -115,3 +148,61 @@ export const aiTestOverrideSchema = z
|
||||
.strict();
|
||||
|
||||
export type AiTestOverrideInput = z.infer<typeof aiTestOverrideSchema>;
|
||||
|
||||
/**
|
||||
* One entry of `PUT /api/insights/provider-chain`. Shared by the route and the
|
||||
* OpenAPI registry, so the published contract and the parser are one object.
|
||||
*/
|
||||
export const providerChainEntrySchema = z
|
||||
.object({
|
||||
providerType: z
|
||||
.enum(PROVIDER_CHAIN_TYPES)
|
||||
.describe(
|
||||
"Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.",
|
||||
),
|
||||
// Priority is recomputed server-side from insertion order so a stale
|
||||
// client cannot persist a chain whose displayed order disagrees with
|
||||
// its priority field. The number is accepted (and may be present) but
|
||||
// ignored on the wire.
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
"ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
reasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as `reasoning_effort`.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((entry, ctx) => {
|
||||
if (
|
||||
entry.reasoningEffort !== undefined &&
|
||||
!isReasoningProviderType(entry.providerType)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["reasoningEffort"],
|
||||
message:
|
||||
"A reasoning setting applies to the local and openai-compatible providers only",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const providerChainPutSchema = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(providerChainEntrySchema)
|
||||
.min(1, "Chain must contain at least one provider")
|
||||
.max(PROVIDER_CHAIN_TYPES.length, "Too many providers")
|
||||
.describe(
|
||||
"The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ProviderChainPutInput = z.infer<typeof providerChainPutSchema>;
|
||||
@@ -625,6 +625,15 @@ const cycleSymptomSeveritySchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** The sensitive-category envelope's five fields, as a portable file carries them. */
|
||||
const cycleSensitiveFieldsSchema = z.object({
|
||||
sexualActivity: z.boolean().optional(),
|
||||
protectedSex: z.boolean().nullable().optional(),
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
});
|
||||
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -644,8 +653,20 @@ const cycleDayLogSchema = z
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
// Free text travels like every other note: a disaster-recovery file
|
||||
// carries the ciphertext (`*Encrypted`), a portable file the readable
|
||||
// value (`note`, `sensitive`), which the restore seals under the
|
||||
// receiving host's key. A portable file written before v1.40 carries the
|
||||
// ciphertext too; the restore keeps only what this host can open.
|
||||
sensitiveEncrypted: z.string().nullable().optional(),
|
||||
notesEncrypted: z.string().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
// The five intent fields the sensitive-category envelope holds, or the
|
||||
// unreadable marker when the writing host could not open the envelope.
|
||||
sensitive: z
|
||||
.union([cycleSensitiveFieldsSchema, z.string()])
|
||||
.nullable()
|
||||
.optional(),
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
tz: z.string().nullable().optional(),
|
||||
@@ -696,10 +717,12 @@ const customMoodTagSchema = z
|
||||
isActive: z.boolean().default(true),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
// The user's own words for the tag, encrypted at rest and carried
|
||||
// verbatim. Without it the tag comes back as a bare key and the person
|
||||
// who named it "Migräne" gets `custom:cm3x9…` instead.
|
||||
// The user's own words for the tag. Without it the tag comes back as a
|
||||
// bare key and the person who named it "Migräne" gets `custom:cm3x9…`
|
||||
// instead. Ciphertext on a disaster-recovery file, readable `label` on a
|
||||
// portable one; the restore seals the readable value under its own key.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
// The scale a RATED factor was recorded on. `inverse` marks a factor
|
||||
// where a HIGH value is bad (stress, conflict). Dropping it does not lose
|
||||
// a label, it silently reverses the meaning of every rating already
|
||||
@@ -752,7 +775,10 @@ const customCycleSymptomSchema = z
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
// Same reasoning as the mood tag's: the user's own words live here.
|
||||
// Ciphertext on a disaster-recovery file, readable `label` on a portable
|
||||
// one (the day-log note's split).
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1181,8 +1207,9 @@ const symptomDefinitionBackupSchema = z
|
||||
*
|
||||
* `id` is required on both records, unlike the lab result above: a link
|
||||
* addresses an encounter and its far side by id and nothing else on either row
|
||||
* is unique enough to rebuild the reference from. The ciphertext columns ride
|
||||
* verbatim as base64 — a visit note is never decrypted into the file.
|
||||
* is unique enough to rebuild the reference from. Free text follows the note
|
||||
* contract: ciphertext as base64 on a disaster-recovery file, the readable
|
||||
* value on a portable one (sealed under the receiving host's key on restore).
|
||||
*/
|
||||
const practitionerBackupSchema = z
|
||||
.object({
|
||||
@@ -1196,6 +1223,7 @@ const practitionerBackupSchema = z
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
phoneEncrypted: base64BytesSchema.nullable().optional(),
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1214,6 +1242,10 @@ const encounterBackupSchema = z
|
||||
// v1.39.1 — the procedure's body site and side. Optional so a file written
|
||||
// before they existed still parses; the visit restores with neither.
|
||||
bodySiteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
// The readable reason, outcome and body site of a portable file.
|
||||
reason: z.string().nullable().optional(),
|
||||
outcome: z.string().nullable().optional(),
|
||||
bodySite: z.string().nullable().optional(),
|
||||
laterality: z.enum(Laterality).nullable().optional(),
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
@@ -1255,7 +1287,9 @@ const vaccinationBackupSchema = z
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
// Ciphertext on a disaster-recovery file, readable on a portable one.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1618,7 +1652,9 @@ const customMoodTagCategorySchema = z
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Same split as the custom tag's label.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ describe("buildManualWorkoutEntry — validation", () => {
|
||||
describe("the form's defaults", () => {
|
||||
it("opens with a blank start; the field caps at the current minute", () => {
|
||||
expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00");
|
||||
expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({
|
||||
expect(emptyManualWorkoutDraft()).toMatchObject({
|
||||
sportType: "",
|
||||
start: "",
|
||||
hours: "",
|
||||
|
||||
@@ -118,10 +118,7 @@ export function wallClockNow(now: Date, timezone: string): string {
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
|
||||
}
|
||||
|
||||
export function emptyManualWorkoutDraft(
|
||||
now: Date,
|
||||
timezone: string,
|
||||
): ManualWorkoutDraft {
|
||||
export function emptyManualWorkoutDraft(): ManualWorkoutDraft {
|
||||
return {
|
||||
sportType: "",
|
||||
// Blank on purpose (#1085): a blank start means "it just ended", so the
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -391,8 +391,10 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
sexualActivity: false,
|
||||
sensitiveEncrypted: "sensitive-cycle-dr",
|
||||
notesEncrypted: "notes-cycle-dr",
|
||||
// Real ciphertext: the restore writes a sealed value back only when
|
||||
// this host opens it.
|
||||
sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })),
|
||||
notesEncrypted: encrypt("notes-cycle-dr"),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "cycle-day-external-dr",
|
||||
tz: "Europe/London",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* A portable file's cycle free text survives a move to a host with other keys.
|
||||
*
|
||||
* The day-log note, the sensitive-category envelope and a custom symptom's
|
||||
* label are sealed under the writing host's key. A portable file used to carry
|
||||
* those three as the stored ciphertext, so a restore onto a host with a
|
||||
* different key either refused the whole file at the key check or, with a
|
||||
* matching key id over different key material, wrote rows nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: the file is built by the real `buildFullBackupPayload`
|
||||
* under key A, stored and restored through the real route under key B, and
|
||||
* the restored rows are opened under key B.
|
||||
*
|
||||
* The second case is the file written before the fix, which still carries
|
||||
* ciphertext. A value this host's keys open comes back; a value they cannot
|
||||
* open is kept out of the row and named in the skip report, rather than
|
||||
* written back as ciphertext no reader can open.
|
||||
*
|
||||
* Mutation: restore the verbatim copy of `notesEncrypted` in
|
||||
* `restoreCycleData` and the legacy case writes the foreign ciphertext back.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const NOTE = "Cramps woke me at four";
|
||||
const SENSITIVE = {
|
||||
sexualActivity: true,
|
||||
protectedSex: false,
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: null,
|
||||
contraceptive: "ORAL",
|
||||
};
|
||||
const LABEL = "Lower back ache";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "cycle-cross-key-owner",
|
||||
email: "cycle-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "CYCLE_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("cycle free text in a portable file restored under another key", () => {
|
||||
it("comes back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
/* ── host A writes the account and exports it ─────────────────────── */
|
||||
useKeys(HOST_A, "a");
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: "custom:cross-key-ache",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt(LABEL),
|
||||
},
|
||||
});
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
date: "2026-06-02",
|
||||
flow: "MEDIUM",
|
||||
notesEncrypted: encrypt(NOTE),
|
||||
sensitiveEncrypted: encrypt(JSON.stringify(SENSITIVE)),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
/* ── host B, which has never held key A, restores it ──────────────── */
|
||||
useKeys(HOST_B, "b");
|
||||
// Host B is another database: the account's own symptom row from host A
|
||||
// is not there (the restore upserts by key and keeps an existing row).
|
||||
await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } });
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const day = await prisma.cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
expect(day.flow).toBe("MEDIUM");
|
||||
expect(day.notesEncrypted).not.toBeNull();
|
||||
expect(decrypt(day.notesEncrypted!)).toBe(NOTE);
|
||||
expect(day.sensitiveEncrypted).not.toBeNull();
|
||||
expect(JSON.parse(decrypt(day.sensitiveEncrypted!))).toEqual(SENSITIVE);
|
||||
|
||||
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
||||
where: { key: "custom:cross-key-ache" },
|
||||
});
|
||||
expect(decrypt(symptom.labelEncrypted!)).toBe(LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
// Written under host B's own key: opens here.
|
||||
useKeys(HOST_B, "b");
|
||||
const ownNote = encrypt(NOTE);
|
||||
// Written elsewhere: an id host B does not hold, and host B's id over
|
||||
// different key material.
|
||||
useKeys(HOST_A, "a");
|
||||
const foreignNote = encrypt("written under key a");
|
||||
const foreignLabel = encrypt("label under key a");
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorSensitive = encrypt(JSON.stringify(SENSITIVE));
|
||||
|
||||
// The shape a portable file had before the fix: every free-text field as
|
||||
// the stored ciphertext, no plaintext beside it.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
file.customSymptoms = [
|
||||
{
|
||||
key: "custom:legacy-foreign",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
categoryId: category.id,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: foreignLabel,
|
||||
},
|
||||
];
|
||||
const day = (
|
||||
date: string,
|
||||
notes: string | null,
|
||||
sensitive: string | null,
|
||||
) => ({
|
||||
date,
|
||||
flow: "LIGHT",
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: sensitive,
|
||||
notesEncrypted: notes,
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
tz: null,
|
||||
symptomKeys: [],
|
||||
symptomSeverities: [],
|
||||
});
|
||||
file.cycleDayLogs = [
|
||||
day("2026-06-01", ownNote, null),
|
||||
day("2026-06-02", foreignNote, impostorSensitive),
|
||||
];
|
||||
// Still a file this release accepts.
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const days = await prisma.cycleDayLog.findMany({
|
||||
where: { userId: owner.id },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
expect(days.map((d) => d.date)).toEqual(["2026-06-01", "2026-06-02"]);
|
||||
expect(decrypt(days[0].notesEncrypted!)).toBe(NOTE);
|
||||
// The day itself comes back; only what cannot be opened stays out.
|
||||
expect(days[1].flow).toBe("LIGHT");
|
||||
expect(days[1].notesEncrypted).toBeNull();
|
||||
expect(days[1].sensitiveEncrypted).toBeNull();
|
||||
|
||||
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
||||
where: { key: "custom:legacy-foreign" },
|
||||
});
|
||||
expect(symptom.labelEncrypted).toBeNull();
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "customSymptoms.custom:legacy-foreign.labelEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "cycleDayLogs.2026-06-02.notesEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "cycleCiphertext",
|
||||
key: "cycleDayLogs.2026-06-02.sensitiveEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
expect(body.data.skipped.links).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* A portable file's custom mood labels survive a move to a host with other
|
||||
* keys.
|
||||
*
|
||||
* The labels of the mood tags and mood tag categories a person created are
|
||||
* sealed under the writing host's key. A portable file used to carry them as
|
||||
* the stored ciphertext, so a restore onto a host with a different key
|
||||
* refused the file at the key check, or with a matching key id over other key
|
||||
* material wrote labels nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
|
||||
* A, restored through the real route under key B, opened under key B. The
|
||||
* second case is a file written before the fix: a label this host opens comes
|
||||
* back, one it cannot open stays out and is named in the skip report.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const CATEGORY_LABEL = "Evenings";
|
||||
const TAG_LABEL = "Migraine";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-cross-key-owner",
|
||||
email: "mood-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "MOOD_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("custom mood labels in a portable file restored under another key", () => {
|
||||
// Neither case logs a mood entry: an account's own tags and categories
|
||||
// travel whether or not it has entries (they used to be written only inside
|
||||
// the restore's entries branch, and a vocabulary set up before the first
|
||||
// entry did not come back).
|
||||
it("come back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_A, "a");
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
key: "customcat:cross-key",
|
||||
labelKey: "mood.category.custom",
|
||||
labelEncrypted: encrypt(CATEGORY_LABEL),
|
||||
},
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: "custom:cross-key",
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
labelEncrypted: encrypt(TAG_LABEL),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
// Host B is another database: the rows host A wrote are not there.
|
||||
useKeys(HOST_B, "b");
|
||||
await prisma.moodTag.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } });
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL);
|
||||
const restoredTag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:cross-key" },
|
||||
});
|
||||
expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's label ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const ownLabel = encrypt(TAG_LABEL);
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorLabel = encrypt(CATEGORY_LABEL);
|
||||
|
||||
// The shape a portable file had before the fix: labels as ciphertext.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
file.customMoodTagCategories = [
|
||||
{
|
||||
id: "legacy-category-id",
|
||||
key: "customcat:legacy",
|
||||
labelKey: "mood.category.custom",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: impostorLabel,
|
||||
},
|
||||
];
|
||||
file.customMoodTags = [
|
||||
{
|
||||
key: "custom:legacy",
|
||||
labelKey: "mood.tag.custom",
|
||||
categoryId: "legacy-category-id",
|
||||
kind: "BINARY",
|
||||
isActive: true,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
labelEncrypted: ownLabel,
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
},
|
||||
];
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const category = await prisma.moodTagCategory.findUniqueOrThrow({
|
||||
where: { key: "customcat:legacy" },
|
||||
});
|
||||
expect(category.labelEncrypted).toBeNull();
|
||||
const tag = await prisma.moodTag.findUniqueOrThrow({
|
||||
where: { key: "custom:legacy" },
|
||||
});
|
||||
expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "moodLabelCiphertext",
|
||||
key: "customMoodTagCategories.customcat:legacy.labelEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
TWO_ENDED_MODELS,
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
@@ -3694,6 +3695,12 @@ function lostColumns(
|
||||
return { lost: lost.sort(), compared };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections a portable file may carry stored ciphertext in, each with the
|
||||
* reason. Empty: every sealed column travels readable in a portable file.
|
||||
*/
|
||||
const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
describe("every column of every two-ended model survives a real restore", () => {
|
||||
it("brings each column back with the value it left with", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -3758,6 +3765,58 @@ describe("every column of every two-ended model survives a real restore", () =>
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt },
|
||||
);
|
||||
|
||||
// ── No ciphertext in a portable file ──────────────────────────────────
|
||||
//
|
||||
// Every sealed column is filled at this point, so the portable file of
|
||||
// this account is the one place a column still exported as stored
|
||||
// ciphertext shows up. A portable file is read and restored on hosts
|
||||
// with other keys; ciphertext in it is noise there, and a restore either
|
||||
// refuses the file or writes values nobody can open. That class was
|
||||
// fixed one section at a time (cycle, mood labels, visits, vaccinations)
|
||||
// and kept coming back, because each builder decides its own encoding.
|
||||
// This reads what the builders actually wrote, rather than how their
|
||||
// source is spelt. Limit: it sees members named `…Encrypted` only, the
|
||||
// naming the key preflight relies on too.
|
||||
const sealedIn = (text: string) => {
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.visit(JSON.parse(text));
|
||||
const sections = new Map<string, number>();
|
||||
for (const [, use] of collector.entries()) {
|
||||
for (const section of use.sections) {
|
||||
sections.set(section, (sections.get(section) ?? 0) + use.count);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
};
|
||||
// The floor: the same matcher over the disaster-recovery file of the same
|
||||
// account, which carries every sealed column as ciphertext. A matcher
|
||||
// that finds nothing there would make the portable check pass vacuously.
|
||||
const drSealed = sealedIn(json);
|
||||
expect(
|
||||
[...drSealed.values()].reduce((a, b) => a + b, 0),
|
||||
).toBeGreaterThanOrEqual(40);
|
||||
expect(drSealed.size).toBeGreaterThanOrEqual(15);
|
||||
let portableJson = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
portableJson += chunk;
|
||||
},
|
||||
{ purpose: "portable-export", exportedAt },
|
||||
);
|
||||
const portableSealed = [...sealedIn(portableJson).keys()].filter(
|
||||
(section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section),
|
||||
);
|
||||
expect(
|
||||
portableSealed,
|
||||
"a portable file carries these sections' sealed columns as stored " +
|
||||
"ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " +
|
||||
"and seal them again on restore, or name the section in " +
|
||||
"PORTABLE_CIPHERTEXT_ALLOWED with the reason",
|
||||
).toEqual([]);
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* A portable file's visit and vaccination free text survives a move to a host
|
||||
* with other keys.
|
||||
*
|
||||
* A practitioner's note, a visit's reason, outcome and body site and a
|
||||
* vaccination's note are sealed under the writing host's key. A portable file
|
||||
* used to carry them as the stored ciphertext, so a restore onto a host with
|
||||
* a different key refused the file at the key check, or with a matching key id
|
||||
* over other key material wrote values nobody could open.
|
||||
*
|
||||
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
|
||||
* A, restored through the real route under key B, opened under key B. The
|
||||
* second case is a file written before the fix: a value this host opens comes
|
||||
* back, one it cannot open stays out and is named in the skip report.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
/** Make this process the host holding exactly `keys`, writing under `active`. */
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
const HOST_A = { a: "11".repeat(32) };
|
||||
const HOST_B = { b: "22".repeat(32) };
|
||||
/** Same key id as host B, different key material. */
|
||||
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
|
||||
|
||||
const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "visits-cross-key-owner",
|
||||
email: "visits-cross-key-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Store `payload` as a backup on this host and run the real restore. */
|
||||
async function restore(ownerId: string, payload: unknown) {
|
||||
const prisma = getPrismaClient();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "VISITS_CROSS_KEY_RESTORE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("visit and vaccination free text restored under another key", () => {
|
||||
it("comes back readable under the receiving host's key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_A, "a");
|
||||
const practitioner = await prisma.practitioner.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
name: "Practice",
|
||||
noteEncrypted: encryptToBytes("ring the upper bell"),
|
||||
locationEncrypted: encryptToBytes("Main street 1"),
|
||||
},
|
||||
});
|
||||
const encounter = await prisma.encounter.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
occurredAt: new Date("2026-06-30T08:00:00.000Z"),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: practitioner.id,
|
||||
reasonEncrypted: encryptToBytes("annual check"),
|
||||
outcomeEncrypted: encryptToBytes("all fine"),
|
||||
bodySiteEncrypted: encryptToBytes("left knee"),
|
||||
},
|
||||
});
|
||||
const vaccination = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
|
||||
vaccineName: "Tetanus",
|
||||
noteEncrypted: encryptToBytes("sore arm for a day"),
|
||||
},
|
||||
});
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as unknown;
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const p = await prisma.practitioner.findUniqueOrThrow({
|
||||
where: { id: practitioner.id },
|
||||
});
|
||||
expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell");
|
||||
expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1");
|
||||
const e = await prisma.encounter.findUniqueOrThrow({
|
||||
where: { id: encounter.id },
|
||||
});
|
||||
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
|
||||
expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine");
|
||||
expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee");
|
||||
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: vaccination.id },
|
||||
});
|
||||
expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day");
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a legacy file's ciphertext only where this host can open it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await seedOwner();
|
||||
|
||||
useKeys(HOST_B, "b");
|
||||
const ownReason = b64(encryptToBytes("annual check"));
|
||||
useKeys(HOST_A, "a");
|
||||
const foreignNote = b64(encryptToBytes("written under key a"));
|
||||
useKeys(HOST_B_IMPOSTOR, "b");
|
||||
const impostorOutcome = b64(encryptToBytes("same id, other key"));
|
||||
const impostorVaccination = b64(encryptToBytes("same id, other key"));
|
||||
|
||||
// The shape a portable file had before the fix: ciphertext, no text.
|
||||
useKeys(HOST_B, "b");
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
|
||||
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
|
||||
const at = "2026-06-30T08:00:00.000Z";
|
||||
file.practitioners = [
|
||||
{
|
||||
id: "legacy-practitioner",
|
||||
name: "Practice",
|
||||
specialty: null,
|
||||
practice: null,
|
||||
location: null,
|
||||
phone: null,
|
||||
noteEncrypted: foreignNote,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
file.encounters = [
|
||||
{
|
||||
id: "legacy-encounter",
|
||||
occurredAt: at,
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: "legacy-practitioner",
|
||||
reasonEncrypted: ownReason,
|
||||
outcomeEncrypted: impostorOutcome,
|
||||
bodySiteEncrypted: null,
|
||||
laterality: null,
|
||||
reminderId: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
file.vaccinations = [
|
||||
{
|
||||
id: "legacy-vaccination",
|
||||
occurredAt: at,
|
||||
vaccineName: "Tetanus",
|
||||
noteEncrypted: impostorVaccination,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
];
|
||||
parseBackupPayload(JSON.stringify(file));
|
||||
|
||||
const response = await restore(owner.id, file);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const p = await prisma.practitioner.findUniqueOrThrow({
|
||||
where: { id: "legacy-practitioner" },
|
||||
});
|
||||
expect(p.noteEncrypted).toBeNull();
|
||||
const e = await prisma.encounter.findUniqueOrThrow({
|
||||
where: { id: "legacy-encounter" },
|
||||
});
|
||||
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
|
||||
expect(e.outcomeEncrypted).toBeNull();
|
||||
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: "legacy-vaccination" },
|
||||
});
|
||||
expect(v.vaccineName).toBe("Tetanus");
|
||||
expect(v.noteEncrypted).toBeNull();
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{
|
||||
catalogue: "vaccinationCiphertext",
|
||||
key: "vaccinations.legacy-vaccination.noteEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "visitCiphertext",
|
||||
key: "encounters.legacy-encounter.outcomeEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
{
|
||||
catalogue: "visitCiphertext",
|
||||
key: "practitioners.legacy-practitioner.noteEncrypted",
|
||||
links: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@
|
||||
* into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled,
|
||||
* merging same-day samples into one row with first-write-wins re-import
|
||||
* - the full-backup cycle section round-trips through
|
||||
* `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted`
|
||||
* preserved verbatim and symptom links re-resolved by key
|
||||
* `buildCycleBackupSection` → `restoreCycleData` with the note carried
|
||||
* readable, re-sealed on restore, and symptom links re-resolved by key
|
||||
*/
|
||||
import { writeFileSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
|
||||
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
@@ -163,7 +164,7 @@ describe("cycle import — reproductive HK samples route to CycleDayLog", () =>
|
||||
});
|
||||
|
||||
describe("cycle backup — round-trips through build + restore", () => {
|
||||
it("preserves notesEncrypted ciphertext and symptom links by key", async () => {
|
||||
it("carries the note readable and re-seals it, with symptom links by key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
@@ -184,7 +185,7 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
notesEncrypted: "v1:abc123:cipher-envelope",
|
||||
notesEncrypted: encrypt("Cramps in the evening"),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
@@ -207,9 +208,9 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
const section = await buildCycleBackupSection(prisma, USER_ID);
|
||||
expect(section.cycles).toHaveLength(1);
|
||||
expect(section.cycleDayLogs).toHaveLength(1);
|
||||
expect(section.cycleDayLogs[0].notesEncrypted).toBe(
|
||||
"v1:abc123:cipher-envelope",
|
||||
);
|
||||
// A portable section carries the note readable, never the ciphertext.
|
||||
expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening");
|
||||
expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted");
|
||||
expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]);
|
||||
|
||||
const payload = parseBackupPayload({
|
||||
@@ -234,7 +235,7 @@ describe("cycle backup — round-trips through build + restore", () => {
|
||||
include: { symptomLinks: { include: { symptom: true } } },
|
||||
});
|
||||
expect(restored).toHaveLength(1);
|
||||
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
|
||||
expect(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening");
|
||||
expect(restored[0].cervixPosition).toBe("HIGH");
|
||||
expect(restored[0].cervixFirmness).toBe("SOFT");
|
||||
expect(restored[0].cervixOpening).toBe("OPEN");
|
||||
|
||||
@@ -343,7 +343,10 @@ describe("the backup", () => {
|
||||
const entry = section.encounters.find((row) => row.id === created.id);
|
||||
expect(entry?.kind).toBe("PROCEDURE");
|
||||
expect(entry?.laterality).toBe("BOTH");
|
||||
expect(entry?.bodySiteEncrypted).toEqual(expect.any(String));
|
||||
// A portable section carries the site readable, never the ciphertext; the
|
||||
// restore seals it under this host's key.
|
||||
expect(entry?.bodySite).toBe("Hüfte");
|
||||
expect(entry).not.toHaveProperty("bodySiteEncrypted");
|
||||
|
||||
// Through the file schema, as a restore reads it: a field the schema does
|
||||
// not know would be dropped here and come back as null.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* One `Idempotency-Key`, two different requests.
|
||||
*
|
||||
* Native clients up to 1.1.0 post a manual blood pressure as two
|
||||
* `POST /api/measurements` calls — the systolic half, then the diastolic half —
|
||||
* under the same key. While the replay cell was keyed on
|
||||
* `(user, key, method, path)` alone, the second call was answered with the
|
||||
* first call's cached 201 and the diastolic value was never written. The cell
|
||||
* now carries a fingerprint of the request body: only the same body replays.
|
||||
*
|
||||
* Everything here is real — the shipped route, the shipped wrapper, the
|
||||
* session resolver and the `idempotency_keys` unique index.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { POST as postMeasurement } from "@/app/api/measurements/route";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const USER_A = "idem-fingerprint-a";
|
||||
const USER_B = "idem-fingerprint-b";
|
||||
const KEY = "bp-entry-5c1e2a7f";
|
||||
const MEASURED_AT = "2026-09-30T07:15:00.000Z";
|
||||
|
||||
const SYS = { type: "BLOOD_PRESSURE_SYS", value: 128, measuredAt: MEASURED_AT };
|
||||
const DIA = { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: MEASURED_AT };
|
||||
|
||||
const sessions = new Map<string, string>();
|
||||
|
||||
function post(body: unknown, key = KEY): Promise<Response> {
|
||||
return postMeasurement(
|
||||
new NextRequest("http://localhost/api/measurements", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": key,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function signInAs(userId: string) {
|
||||
cookieJar.set("healthlog_session", sessions.get(userId)!);
|
||||
}
|
||||
|
||||
async function rowsOf(userId: string) {
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
// By name, not by the enum's declaration order.
|
||||
return rows.sort((a, b) => a.type.localeCompare(b.type));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
sessions.clear();
|
||||
for (const id of [USER_A, USER_B]) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
sessions.set(id, session.id);
|
||||
}
|
||||
signInAs(USER_A);
|
||||
});
|
||||
|
||||
describe("Idempotency-Key reused for a different body (real Postgres)", () => {
|
||||
it("writes both halves of a blood pressure posted under one key", async () => {
|
||||
const first = await post(SYS);
|
||||
expect(first.status).toBe(201);
|
||||
|
||||
const second = await post(DIA);
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.headers.get("X-Idempotent-Replay")).toBeNull();
|
||||
|
||||
expect(await rowsOf(USER_A)).toEqual([
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still replays a retry of the same body and writes one row", async () => {
|
||||
const first = await post(SYS);
|
||||
expect(first.status).toBe(201);
|
||||
const firstBody = await first.json();
|
||||
|
||||
// Same object, different key order: the same request.
|
||||
const retry = await post({
|
||||
measuredAt: MEASURED_AT,
|
||||
value: 128,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
});
|
||||
expect(retry.status).toBe(201);
|
||||
expect(retry.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(await retry.json()).toEqual(firstBody);
|
||||
|
||||
expect(await rowsOf(USER_A)).toEqual([
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the first cell answering its own retries after a different body ran", async () => {
|
||||
await post(SYS);
|
||||
await post(DIA);
|
||||
const retry = await post(SYS);
|
||||
expect(retry.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(await rowsOf(USER_A)).toHaveLength(2);
|
||||
const cells = await getPrismaClient().idempotencyKey.findMany({
|
||||
where: { userId: USER_A },
|
||||
});
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells[0]?.requestFingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("writes both halves when they are in flight at the same time", async () => {
|
||||
const [a, b] = await Promise.all([post(SYS), post(DIA)]);
|
||||
expect([a.status, b.status]).toEqual([201, 201]);
|
||||
expect(await rowsOf(USER_A)).toEqual([
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still lets only one of two identical bodies in flight run", async () => {
|
||||
const [a, b] = await Promise.all([post(SYS), post(SYS)]);
|
||||
expect([a.status, b.status].sort()).toEqual([201, 409]);
|
||||
expect(await rowsOf(USER_A)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not replay a row written before fingerprints existed", async () => {
|
||||
await getPrismaClient().idempotencyKey.create({
|
||||
data: {
|
||||
userId: USER_A,
|
||||
key: KEY,
|
||||
method: "POST",
|
||||
path: "/api/measurements",
|
||||
responseStatus: 201,
|
||||
responseBody: JSON.stringify({ data: { id: "legacy" }, error: null }),
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await post(DIA);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("X-Idempotent-Replay")).toBeNull();
|
||||
expect(await rowsOf(USER_A)).toEqual([
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
|
||||
]);
|
||||
// The legacy row is left alone, not claimed over.
|
||||
const cell = await getPrismaClient().idempotencyKey.findFirstOrThrow({
|
||||
where: { userId: USER_A, key: KEY },
|
||||
});
|
||||
expect(cell.requestFingerprint).toBeNull();
|
||||
});
|
||||
|
||||
it("never hands one user's cached response to another using the same key", async () => {
|
||||
const first = await post(SYS);
|
||||
const firstBody = await first.json();
|
||||
|
||||
signInAs(USER_B);
|
||||
const other = await post(SYS);
|
||||
expect(other.status).toBe(201);
|
||||
expect(other.headers.get("X-Idempotent-Replay")).toBeNull();
|
||||
expect(await other.json()).not.toEqual(firstBody);
|
||||
|
||||
expect(await rowsOf(USER_A)).toHaveLength(1);
|
||||
expect(await rowsOf(USER_B)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the documented operator query finds a systolic reading left without its diastolic half", async () => {
|
||||
const doc = readFileSync(
|
||||
join(process.cwd(), "docs/ops/blood-pressure-missing-diastolic.md"),
|
||||
"utf8",
|
||||
);
|
||||
const block =
|
||||
/<!-- bp-missing-diastolic:start -->([\s\S]*?)<!-- bp-missing-diastolic:end -->/.exec(
|
||||
doc,
|
||||
);
|
||||
const sql = block?.[1]
|
||||
.replace(/```sql|```/g, "")
|
||||
.trim()
|
||||
.replace(/;$/, "");
|
||||
expect(sql).toBeTruthy();
|
||||
|
||||
// A complete reading through the fixed path, and the damage the old
|
||||
// cache left behind: a systolic half on its own.
|
||||
await post(SYS);
|
||||
await post(DIA);
|
||||
const lone = await getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId: USER_A,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 141,
|
||||
unit: "mmHg",
|
||||
measuredAt: new Date("2026-09-29T19:40:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const found = await getPrismaClient().$queryRawUnsafe<
|
||||
Array<{ id: string; systolic: number }>
|
||||
>(sql!);
|
||||
expect(found.map((r) => [r.id, r.systolic])).toEqual([[lone.id, 141]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A medication PUT the course writer refuses changes nothing.
|
||||
*
|
||||
* The PUT checks the window against the other courses first, then writes the
|
||||
* row, then writes the course. A course written by another request between
|
||||
* the check and the course write makes the course write refuse. If the row
|
||||
* write had already committed, the client got an error while the name, the
|
||||
* schedule and `oneShot` were changed anyway: a one-time medication with a
|
||||
* window that spans more than its one day.
|
||||
*
|
||||
* The race is reproduced by letting the up-front check pass (it is the check
|
||||
* the other request outran), so the real course writer meets the conflicting
|
||||
* courses and refuses, through the shipped route and a real Postgres.
|
||||
*
|
||||
* Mutation: move the row update back out of the transaction the course write
|
||||
* runs in, and the name and `oneShot` assertions turn red.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const OWNER = "user-med-put-race";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
// The check the concurrent request outran: it saw no conflict.
|
||||
vi.mock("@/lib/medications/courses", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/medications/courses")>()),
|
||||
checkCurrentWindow: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER,
|
||||
username: OWNER,
|
||||
email: `${OWNER}@example.test`,
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function req(method: string, path: string, body?: unknown) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("a medication PUT refused by the course writer", () => {
|
||||
it("leaves the medication row, its schedule and its courses as they were", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { POST } = await import("@/app/api/medications/route");
|
||||
const created = await POST(
|
||||
req("POST", "/api/medications", {
|
||||
name: "Tamiflu",
|
||||
dose: "75 mg",
|
||||
startsOn: "2026-03-01",
|
||||
endsOn: "2026-03-07",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const { id } = (await created.json()).data as { id: string };
|
||||
|
||||
// The course the other request wrote.
|
||||
const { POST: postCourse } =
|
||||
await import("@/app/api/medications/[id]/courses/route");
|
||||
const course = await postCourse(
|
||||
req("POST", `/api/medications/${id}/courses`, {
|
||||
startsOn: "2026-06-10",
|
||||
endsOn: "2026-06-16",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(course.status).toBe(201);
|
||||
|
||||
const before = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
|
||||
});
|
||||
const revisionsBefore = await prisma.medicationScheduleRevision.count({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
|
||||
// A one-time medication cannot carry two courses: the writer refuses.
|
||||
const { PUT } = await import("@/app/api/medications/[id]/route");
|
||||
const res = await PUT(
|
||||
req("PUT", `/api/medications/${id}`, {
|
||||
name: "Oseltamivir",
|
||||
oneShot: true,
|
||||
startsOn: "2026-03-05",
|
||||
schedules: [{ windowStart: "20:00", windowEnd: "21:00" }],
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./);
|
||||
|
||||
const after = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
|
||||
});
|
||||
expect(after.name).toBe("Tamiflu");
|
||||
expect(after.oneShot).toBe(false);
|
||||
expect(after.startsOn).toEqual(before.startsOn);
|
||||
expect(after.endsOn).toEqual(before.endsOn);
|
||||
expect(after.updatedAt).toEqual(before.updatedAt);
|
||||
expect(after.schedules.map((s) => s.id)).toEqual(
|
||||
before.schedules.map((s) => s.id),
|
||||
);
|
||||
expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]);
|
||||
expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual(
|
||||
before.courses.map((c) => [c.startsOn, c.endsOn]),
|
||||
);
|
||||
expect(
|
||||
await prisma.medicationScheduleRevision.count({
|
||||
where: { medicationId: id },
|
||||
}),
|
||||
).toBe(revisionsBefore);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user