Merge branch 'fix/v1463-review' into release/v1.46.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 22:18:02 +01:00
commit 1f0b05f141
58 files changed
+1468 -245

No files matched your search

+50 -45
View File
@@ -25189,10 +25189,10 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "The chosen target cannot take the copy, and nothing was stored: a managed profile
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an
account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username
already taken (`backup.target.username_taken`), or a file whose own account is still here
(`backup.owner.exists`)."
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin
account (`backup.target.admin_account`), an account that already holds records
(`backup.target.holds_records`, `meta.models` naming them), a username already taken
(`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`)."
content:
application/json:
schema:
@@ -26190,17 +26190,19 @@ paths:
- in: query
name: values
schema:
description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike
names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is
left out of `labSeries` without an error, so a stored choice never breaks the page.
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab
markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones
left out without an error. A lab marker that is not in the record (deleted, or another record's) is
likewise left out of `labSeries`, so a stored choice never breaks the page."
type: string
description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike
names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left
out of `labSeries` without an error, so a stored choice never breaks the page.
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers
are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out
without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out
of `labSeries`, so a stored choice never breaks the page."
responses:
"200":
description: The timeline for the requested zoom and window.
@@ -38714,36 +38716,37 @@ components:
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
backup.payload.undecryptable, backup.restore.active, backup.section.missing,
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle —
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere,
backup.section.missing, backup.target.admin_account, backup.target.holds_records,
backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion,
backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers —
biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed;
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted,
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
@@ -90610,9 +90613,11 @@ components:
type: string
description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`,
`incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`,
`document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`,
`transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`.
Every code but `failed_after_commit` means the account was not changed."
`document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`,
`backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account
already holds, because the same file was restored there before), `time_budget`, `transaction_failed`,
`interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but
`failed_after_commit` means the account was not changed."
message:
type: string
sections:
+1 -1
View File
@@ -692,7 +692,7 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a credential reconnected to another account starts empty. Two sign-ins on different plans no longer overwrite each other's working slug.
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug.
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
+30 -5
View File
@@ -103,18 +103,35 @@ exists, and the console then asks where the records should go:
password or email, as an ordinary account with the user role, and nobody
can sign into it yet. Before handing it over, set its first password with
**Reset password** in Admin → Users; the person can change it, add an email
and enrol a passkey from their settings.
and enrol a passkey from their settings. The account is made before the
file is read, with an `admin.user.create` audit row (`outcome: "created"`),
and removed again when the file is refused, with an `admin.user.delete` row
(`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`,
the removal did not happen and the account is left over, holding nothing:
delete it in Admin → Users.
- **An existing account**, for someone who has signed up again. Pick the
account and type its username to confirm. The account must hold no records
yet: a restore replaces an account's records and does not merge them, so an
account with even one record of any kind a copy carries is refused, with the
kinds named. Managed profiles and accounts pending deletion are not offered.
kinds named. What signing up and the setup questions leave behind does not
count (the setup answers and the cycle settings): the restore replaces both
with the copy's. A reading or medication entered on setup's last screen is a
record, and the account is refused until it is deleted, because the restore
would delete it too. Managed profiles, accounts pending deletion and admin
accounts are not offered: an admin account is the operator's, its settings
are read under the operator's wider AI endpoint policy, and its role reaches
every account, so another person's record does not go into it.
The file is then uploaded a second time, and its owner is changed to the chosen
account as it is read. Nothing else in the file changes. The stored copy is
then that account's copy in every respect: its preview, its download and its
restore are the ordinary ones, and **Restore** on its row brings everything
back under the chosen account. The erased account's id is not stored, not in
then that account's copy in every respect but one: its preview, its download
and its restore are the ordinary ones, and **Restore** on its row brings
everything back under the chosen account. The one difference is that it is
stored as `RETARGETED_UPLOAD_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
restore reads the file's settings as the file's author's, never as the
operator's: an AI endpoint on a private address comes back only when this host
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
been made an admin since. The erased account's id is not stored, not in
the copy and not in the audit trail.
A copy whose own account still exists on this server can only go back into
@@ -126,6 +143,14 @@ restored managed profile is an ordinary account.
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
A file goes into one account only. Its records keep the ids they had in the
erased account, so once it has been restored into one account, restoring the
same file into a second account would write those ids twice. The upload still
stores the second copy, but its restore is refused with
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
either account. To move the record to another account, delete the first
account (or its records) before restoring into the second.
### What a restore does not touch
API tokens, trusted devices, step-up elevations, known devices, clinician share
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.",
"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.",
"restoreFailureRowsElsewhere": "Die Datensätze dieser Datei liegen schon in einem anderen Konto auf diesem Server: Dieselbe Datei wurde dort bereits wiederhergestellt. Eine Datei lässt sich nur in ein Konto wiederherstellen. 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)",
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
"failureAdminAccount": "Das ist ein Admin-Konto. Die Datensätze einer anderen Person können nicht hinein. Wähle ein normales Konto oder lege ein neues an.",
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Laborwerte",
"otherPanel": "Weitere",
"full": "Bis zu {max} Laborwerte gleichzeitig. Entferne einen, um einen anderen hinzuzufügen.",
"above": "über dem Referenzbereich",
"below": "unter dem Referenzbereich",
"reference": "Referenz {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.",
"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.",
"restoreFailureRowsElsewhere": "The records in this file are already in another account on this server: the same file was restored there before. A file can be restored into one account only. 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)",
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
"failureAdminAccount": "This is an admin account. Another person's records cannot go into it. Choose an ordinary account or create a new one.",
"failureNotFound": "This account no longer exists.",
"failureUnconfirmed": "Type the account's username exactly to confirm.",
"failureUsernameTaken": "This username is already taken.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Lab values",
"otherPanel": "Other",
"full": "Up to {max} lab values at once. Remove one to add another.",
"above": "above range",
"below": "below range",
"reference": "reference {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.",
"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.",
"restoreFailureRowsElsewhere": "Los registros de este archivo ya están en otra cuenta de este servidor: el mismo archivo ya se restauró allí. Un archivo solo puede restaurarse en una 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)",
"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)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
"failureAdminAccount": "Esta es una cuenta de administrador. Los registros de otra persona no pueden ir en ella, así que elige una cuenta normal o crea una nueva.",
"failureNotFound": "Esta cuenta ya no existe.",
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Valores de laboratorio",
"otherPanel": "Otros",
"full": "Hasta {max} valores de laboratorio a la vez. Quita uno para añadir otro.",
"above": "por encima del intervalo",
"below": "por debajo del intervalo",
"reference": "referencia {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.",
"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.",
"restoreFailureRowsElsewhere": "Les enregistrements de ce fichier se trouvent déjà dans un autre compte de ce serveur : le même fichier y a déjà été restauré. Un fichier ne peut être restauré que dans un seul 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)",
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.",
"failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.",
"failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.",
"failureAdminAccount": "Il s’agit d’un compte administrateur. Les enregistrements d’une autre personne ne peuvent pas y être placés : choisissez un compte ordinaire ou créez-en un nouveau.",
"failureNotFound": "Ce compte n'existe plus.",
"failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.",
"failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Valeurs de laboratoire",
"otherPanel": "Autres",
"full": "Jusqu’à {max} valeurs de laboratoire à la fois. Retirez-en une pour en ajouter une autre.",
"above": "au-dessus de l’intervalle",
"below": "en dessous de l’intervalle",
"reference": "référence {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.",
"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.",
"restoreFailureRowsElsewhere": "I record di questo file sono già in un altro account di questo server: lo stesso file è già stato ripristinato lì. Un file può essere ripristinato in un solo 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)",
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
"failureAdminAccount": "Questo è un account amministratore. I record di un’altra persona non possono andarci, quindi scegli un account normale o creane uno nuovo.",
"failureNotFound": "Questo account non esiste più.",
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
"failureUsernameTaken": "Questo nome utente è già in uso.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Valori di laboratorio",
"otherPanel": "Altri",
"full": "Fino a {max} valori di laboratorio alla volta. Rimuovine uno per aggiungerne un altro.",
"above": "sopra l’intervallo",
"below": "sotto l’intervallo",
"reference": "riferimento {range}",
+3
View File
@@ -8320,6 +8320,7 @@
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.",
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
"restoreFailureRowsElsewhere": "이 파일의 기록은 이미 이 서버의 다른 계정에 있습니다. 같은 파일이 그 계정에 먼저 복원되었습니다. 파일은 한 계정에만 복원할 수 있습니다. 아무것도 기록되지 않았습니다.",
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
@@ -8372,6 +8373,7 @@
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
"failureAdminAccount": "관리자 계정이에요. 다른 사람의 기록은 이 계정에 넣을 수 없으니 일반 계정을 고르거나 새 계정을 만들어 주세요.",
"failureNotFound": "이 계정은 더 이상 없어요.",
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "검사 수치",
"otherPanel": "기타",
"full": "검사 수치는 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 수치를 추가하려면 하나를 제거하세요.",
"above": "범위 초과",
"below": "범위 미만",
"reference": "참고 범위 {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Het terugzetten stopte door een onverwachte fout en is teruggedraaid. In het serverlog staat de fout.",
"restoreFailureKeyMissing": "De back-up heeft versleutelingssleutel {keys} nodig, waarmee deze server hem niet kan openen. Voeg de sleutel weer toe aan ENCRYPTION_KEYS, herstart en start het terugzetten opnieuw.",
"restoreFailureForeignReference": "Het bestand verwijst naar records die bij een ander account horen. Er is niets geschreven.",
"restoreFailureRowsElsewhere": "De records in dit bestand staan al in een ander account op deze server: hetzelfde bestand is daar al teruggezet. Een bestand kan maar in één account worden teruggezet. Er is niets geschreven.",
"previewKeyMissing": "Deze back-up kan niet op deze server worden teruggezet: er is versleutelingssleutel {keys} voor nodig. Voeg de sleutel eerst weer toe aan ENCRYPTION_KEYS en herstart.",
"restoreSkippedSymptomEpisodeReference": "Symptoomregistratie gekoppeld aan een ziekte die niet in het bestand staat (behouden zonder de koppeling)",
"restoreSkippedCycleCiphertext": "Cyclusnotitie of -label versleuteld met een sleutel die deze server niet heeft (dag of symptoom behouden zonder)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
"failureAdminAccount": "Dit is een beheerdersaccount. De records van iemand anders kunnen er niet in, dus kies een gewoon account of maak een nieuw account aan.",
"failureNotFound": "Dit account bestaat niet meer.",
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Labwaarden",
"otherPanel": "Overig",
"full": "Maximaal {max} labwaarden tegelijk. Verwijder er een om een andere toe te voegen.",
"above": "boven het bereik",
"below": "onder het bereik",
"reference": "referentie {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.",
"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.",
"restoreFailureRowsElsewhere": "Rekordy z tego pliku są już na innym koncie na tym serwerze: ten sam plik został tam wcześniej przywrócony. Plik można przywrócić tylko na jedno konto. 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)",
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
"failureAdminAccount": "To jest konto administratora. Nie można w nim umieścić rekordów innej osoby, więc wybierz zwykłe konto albo utwórz nowe.",
"failureNotFound": "To konto już nie istnieje.",
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Wyniki badań",
"otherPanel": "Inne",
"full": "Naraz można pokazać do {max} wyników badań. Usuń jeden, aby dodać inny.",
"above": "powyżej zakresu",
"below": "poniżej zakresu",
"reference": "zakres {range}",
+3
View File
@@ -8345,6 +8345,7 @@
"restoreFailureUnexpected": "A restauração parou em um erro inesperado e foi revertida. O log do servidor nomeia o erro.",
"restoreFailureKeyMissing": "O backup precisa da chave de criptografia {keys}, que este servidor não consegue usar para abri-lo. Adicione a chave de volta a ENCRYPTION_KEYS, reinicie e inicie a restauração novamente.",
"restoreFailureForeignReference": "O arquivo aponta para registros que pertencem a outra conta. Nada foi gravado.",
"restoreFailureRowsElsewhere": "Os registros deste arquivo já estão em outra conta deste servidor: o mesmo arquivo já foi restaurado nela. Um arquivo só pode ser restaurado em uma conta. Nada foi gravado.",
"previewKeyMissing": "Este backup não pode ser restaurado neste servidor: ele precisa da chave de criptografia {keys}. Adicione a chave de volta a ENCRYPTION_KEYS e reinicie primeiro.",
"restoreSkippedSymptomEpisodeReference": "Entrada de sintoma vinculada a uma doença que o arquivo não contém (mantida sem o link)",
"restoreSkippedCycleCiphertext": "Nota ou rótulo do ciclo selado com uma chave que este servidor não possui (dia ou sintoma mantido sem ele)",
@@ -8397,6 +8398,7 @@
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
"failureAdminAccount": "Esta é uma conta de administrador. Os registros de outra pessoa não podem ir para ela, então escolha uma conta comum ou crie uma nova.",
"failureNotFound": "Esta conta não existe mais.",
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
"failureUsernameTaken": "Este nome de usuário já está em uso.",
@@ -13733,6 +13735,7 @@
"labs": {
"menuTitle": "Valores de exames",
"otherPanel": "Outros",
"full": "Até {max} valores de exames ao mesmo tempo. Remova um para adicionar outro.",
"above": "acima da faixa",
"below": "abaixo da faixa",
"reference": "referência {range}",
@@ -1,10 +1,10 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111940,
"total": 111929,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
+66 -28
View File
@@ -10,8 +10,10 @@
* 3. Parses and validates the JSON against the canonical schema.
* 4. Rejects files written by a future schema version (incompatible).
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
* of type `MANUAL_UPLOAD_<unix-ms>` (`RETARGETED_UPLOAD_<unix-ms>` for
* a copy put into a chosen account, `backup-upload-type.ts`) so
* multiple uploads coexist with the rolling `WEEKLY_AUTO` snapshot AND
* with each other.
*
* The file is read as a stream and never held whole (#1031): an export of an
* account with 1.25 million measurements is a few hundred megabytes of JSON,
@@ -85,6 +87,7 @@ import {
type RestoreTargetVerdict,
} from "@/lib/export/restore-target";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
import { uploadedBackupType } from "@/lib/export/backup-upload-type";
import {
readStreamedBackup,
StreamedBackupInvalidError,
@@ -292,17 +295,42 @@ async function checkTargetBeforeRead(
return null;
}
/** Who made an account for a copy, for the trail. */
interface UploadActor {
adminId: string;
ipAddress: string | null;
}
/**
* The account made for a copy. Created before the file is read, because the
* file is rewritten to its id on the way in, and deleted again when nothing
* is stored. No password, no email: nobody can sign into it until an admin
* sets its first password (Admin, Users, Reset password).
* sets its first password (Admin, Users, Reset password). Its audit row
* commits with it, so an account this route made always has a trace, even
* when the upload fails and removing it again fails too.
*/
async function createTargetAccount(username: string): Promise<ChosenTarget> {
async function createTargetAccount(
username: string,
actor: UploadActor,
): Promise<ChosenTarget> {
try {
const account = await prisma.user.create({
data: { username, role: "USER" },
select: { id: true, username: true },
const account = await prisma.$transaction(async (tx) => {
const row = await tx.user.create({
data: { username, role: "USER" },
select: { id: true, username: true },
});
await auditLog("admin.user.create", {
userId: actor.adminId,
ipAddress: actor.ipAddress,
client: tx,
details: {
targetUserId: row.id,
username: row.username,
reason: "backup_restore",
outcome: "created",
},
});
return row;
});
return { ...account, created: true };
} catch (err) {
@@ -352,13 +380,20 @@ async function ownerOfRetargetedFile(
/**
* Delete the account an upload created for its copy, when nothing was stored.
* It holds nothing and nobody can have signed into it (no password, no email),
* so the row is all there is. A failure is noted, never thrown over the
* so the row is all there is. Either way the trail says what became of it: a
* removal that failed leaves an account to delete by hand
* (`docs/ops/backup-restore.md`). A failure is noted, never thrown over the
* refusal the operator needs to see.
*/
async function discardCreatedAccount(userId: string): Promise<void> {
async function discardCreatedAccount(
account: ChosenTarget,
actor: UploadActor,
): Promise<void> {
let outcome: "deleted" | "failed" = "deleted";
try {
await prisma.user.delete({ where: { id: userId } });
await prisma.user.delete({ where: { id: account.id } });
} catch (err) {
outcome = "failed";
annotate({
meta: {
backup_upload_account_discard_failed: true,
@@ -367,6 +402,20 @@ async function discardCreatedAccount(userId: string): Promise<void> {
},
});
}
try {
await auditLog("admin.user.delete", {
userId: actor.adminId,
ipAddress: actor.ipAddress,
details: {
targetUserId: account.id,
username: account.username,
reason: "backup_upload_refused",
outcome,
},
});
} catch {
annotate({ meta: { backup_upload_account_discard_unaudited: true } });
}
}
export const POST = apiHandler(async (request: NextRequest) => {
@@ -376,6 +425,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
await assertRecentCookieProof(admin, session.id);
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
const actor: UploadActor = { adminId: admin.id, ipAddress };
// Rate-limit identical to the manual-run endpoint. Three is plenty for a
// deliberate restore-prep workflow.
@@ -476,10 +526,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
// `MANUAL_UPLOAD_<unix-ms>` keeps the (userId, type) unique-constraint
// intact so multiple uploads can coexist for the same user without
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
// The millisecond suffix keeps uploads and WEEKLY_AUTO apart under the
// (userId, type) unique constraint; a copy put into a chosen account is
// marked as such, for its restore (`backup-upload-type.ts`).
const uploadType = uploadedBackupType(Boolean(choice), Date.now());
let payload: BackupPayload | undefined;
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
@@ -491,7 +541,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let rewrite: BackupOwnerRewrite | null = null;
if (choice?.into === "new") {
try {
target = await createTargetAccount(choice.username);
target = await createTargetAccount(choice.username, actor);
} catch (err) {
if (err instanceof UploadRefused) return refuse(err);
throw err;
@@ -580,7 +630,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
} catch (err) {
// Nothing was stored, so the account made for the copy goes again.
if (chosen?.created) await discardCreatedAccount(chosen.id);
if (chosen?.created) await discardCreatedAccount(chosen, actor);
if (err instanceof UploadRefused) return refuse(err);
// The file was fine; its encrypted copy is larger than the stored-copy
// limit (`BACKUP_MAX_STORED_MB`). That is the operator's to act on, with
@@ -606,18 +656,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
};
const storedOwner = owner as { id: string; username: string } | null;
if (chosen?.created) {
await auditLog("admin.user.create", {
userId: admin.id,
ipAddress,
details: {
targetUserId: chosen.id,
username: chosen.username,
reason: "backup_restore",
backupId: created.id,
},
});
}
await auditLog("admin.backups.upload", {
userId: admin.id,
ipAddress,
@@ -10,6 +10,10 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
import {
codexCredentialKey,
startCodexSignIn,
} from "@/lib/ai/codex-slug-cache";
/**
* Operator-shared central Codex — device-code poll (admin only).
@@ -93,6 +97,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
cookieStore.delete("central_codex_device");
// What this process learned about the old sign-in's models no longer
// describes a different account (`codex-slug-cache.ts`).
startCodexSignIn({
key: codexCredentialKey.central,
accountId: result.creds.accountId,
});
// A new central sign-in: every account's ledger rows describe the refused
// one, and the central connection is one credential shared by all of them.
// #1279 — and every record's note windows close, as on the admin AI
+6
View File
@@ -5,6 +5,10 @@ import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
codexCredentialKey,
forgetCodexSignIn,
} from "@/lib/ai/codex-slug-cache";
/**
* Operator-shared central Codex (ChatGPT subscription) — status + disconnect.
@@ -55,6 +59,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
create: { id: "singleton", adminCodexConnectionStatus: "disconnected" },
});
forgetCodexSignIn(codexCredentialKey.central);
await auditLog("admin.central-codex.disconnected", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
import {
codexCredentialKey,
startCodexSignIn,
} from "@/lib/ai/codex-slug-cache";
/**
* v1.4.7.1: Device-code flow polling endpoint.
@@ -90,6 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
cookieStore.delete("codex_device");
// What this process learned about the old sign-in's models no longer
// describes a different account (`codex-slug-cache.ts`).
startCodexSignIn({
key: codexCredentialKey.user(user.id),
accountId: result.creds.accountId,
});
// A new sign-in: whatever the ledger held against the old one no longer
// describes this credential, and (#1279) the person's notes stop waiting
// out windows it earned, as on a save of their provider settings.
@@ -6,6 +6,10 @@ import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
import {
codexCredentialKey,
forgetCodexSignIn,
} from "@/lib/ai/codex-slug-cache";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -27,6 +31,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
},
});
forgetCodexSignIn(codexCredentialKey.user(user.id));
await auditLog("codex.oauth.disconnected", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -98,6 +98,8 @@ function failureText(
});
case "backup.foreign_reference":
return t("admin.section.backups.restoreFailureForeignReference");
case "backup.restore.rows_exist_elsewhere":
return t("admin.section.backups.restoreFailureRowsElsewhere");
case "time_budget":
return t("admin.section.backups.restoreFailureTimeBudget");
case "transaction_failed":
@@ -80,10 +80,9 @@ function render(
}
describe("pickableAccounts", () => {
it("offers neither managed profiles nor accounts pending deletion", () => {
it("offers neither managed profiles, accounts pending deletion nor admin accounts", () => {
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
"returning-person",
"operator",
]);
});
});
@@ -152,6 +151,9 @@ describe("targetUploadFailureKey", () => {
expect(key("backup.target.pending_deletion")).toBe(
"admin.section.backups.target.failurePendingDeletion",
);
expect(key("backup.target.admin_account")).toBe(
"admin.section.backups.target.failureAdminAccount",
);
expect(key("backup.target.not_found")).toBe(
"admin.section.backups.target.failureNotFound",
);
@@ -190,7 +192,7 @@ describe("<BackupTargetForm>", () => {
{ data: ELIGIBLE, loading: false, error: null },
);
expect(html).toContain("returning-person (r@example.test)");
expect(html).toContain(">operator<");
expect(html).not.toContain(">operator<");
expect(html).not.toContain("managed-cafe01");
expect(html).not.toContain(">leaving<");
expect(html).toContain('data-slot="backup-target-eligible"');
@@ -49,11 +49,15 @@ export interface BackupTargetCheck {
/**
* The accounts the picker offers: not a managed profile (its Guardians run
* it) and not pending deletion. Whether one holds records is the server's to
* it), not pending deletion and not an admin account (the server refuses all
* three, `restore-target.ts`). Whether one holds records is the server's to
* say, per account, once it is picked.
*/
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
return users.filter((user) => !user.managedProfile && user.deletion === null);
return users.filter(
(user) =>
!user.managedProfile && user.deletion === null && user.role !== "ADMIN",
);
}
/**
@@ -87,6 +91,8 @@ export function targetFailureKey(code: string): string {
return "admin.section.backups.target.failureManagedProfile";
case "backup.target.pending_deletion":
return "admin.section.backups.target.failurePendingDeletion";
case "backup.target.admin_account":
return "admin.section.backups.target.failureAdminAccount";
case "backup.target.not_found":
return "admin.section.backups.target.failureNotFound";
case "backup.target.unconfirmed":
@@ -232,6 +232,54 @@ describe("lab lines on the timeline page", () => {
await expect.poll(() => lastTimelineValues(net)).toEqual(["WEIGHT"]);
});
it("keeps a chosen marker that was hidden since, until it is unticked", async () => {
await page.viewport(1280, 900);
window.localStorage.setItem(
VALUES_KEY,
JSON.stringify(["lab:bm-ferritin"]),
);
setSearch("?zoom=all");
// Hidden: the server no longer offers it, and still draws the chosen line.
const net = stubFetch()
.on("GET", "/api/timeline", (request) => {
const body = timeline(valuesOf(request.path));
return {
data: {
...body,
availableLabs: body.availableLabs.filter(
(m) => m.key !== "lab:bm-ferritin",
),
},
};
})
.on("GET", "/api/timeline/readiness", {
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
})
.on("GET", "/api/life-events", { data: { events: [] } });
const { screen } = await renderWithApp(<TimelineView />, {
user: {
timezone: "Europe/Berlin",
modules: { timeline: true, labs: true },
},
});
await screen.getByRole("button", { name: /Values \(1\)/ }).click();
const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" });
await expect.element(ferritin).toHaveAttribute("aria-checked", "true");
await ferritin.click();
await expect.poll(() => lastTimelineValues(net)).toEqual([]);
// Unticked, it is offered no more.
await expect
.poll(
() =>
document.querySelectorAll(
'[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]',
).length,
)
.toBe(0);
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([]);
});
it("neither offers nor asks for a marker with the labs module off", async () => {
await page.viewport(1280, 900);
const { net, screen } = await mount(["WEIGHT", "lab:bm-ferritin"], {
@@ -295,3 +343,69 @@ describe("lab lines on the timeline page", () => {
expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth);
});
});
describe("the lab-marker cap on the timeline page", () => {
const id = (i: number) => `m${String(i).padStart(2, "0")}`;
const marker = (i: number) => ({
key: `lab:${id(i)}`,
biomarkerId: id(i),
name: `Marker ${String(i).padStart(2, "0")}`,
panel: null,
unit: "U/L",
});
/** A record with 26 markers, a stored choice naming 30 (four deleted). */
async function mountMany() {
const stored = Array.from({ length: 30 }, (_, i) => `lab:${id(i)}`);
window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
setSearch("?zoom=all");
const net = stubFetch()
.on("GET", "/api/timeline", () => ({
data: {
...timeline([]),
availableLabs: Array.from({ length: 26 }, (_, i) => marker(i)),
},
}))
.on("GET", "/api/timeline/readiness", {
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
})
.on("GET", "/api/life-events", { data: { events: [] } });
const { screen } = await renderWithApp(<TimelineView />, {
user: {
timezone: "Europe/Berlin",
modules: { timeline: true, labs: true },
},
});
return { net, screen };
}
it("asks for no more markers than the server takes, and holds further ones back until one is removed", async () => {
await page.viewport(1280, 900);
const { net, screen } = await mountMany();
const first24 = Array.from({ length: 24 }, (_, i) => `lab:${id(i)}`);
await expect.poll(() => lastTimelineValues(net)).toEqual(first24);
await screen.getByRole("button", { name: /Values \(24\)/ }).click();
const extra = screen.getByRole("menuitemcheckbox", { name: "Marker 24" });
await expect.element(extra).toHaveAttribute("aria-checked", "false");
await expect.element(extra).toHaveAttribute("aria-disabled", "true");
await expect
.element(
screen.getByText("Up to 24 lab values at once", { exact: false }),
)
.toBeVisible();
// A chosen marker can always be removed.
const chosen = screen.getByRole("menuitemcheckbox", { name: "Marker 00" });
expect(chosen.element().getAttribute("aria-disabled")).not.toBe("true");
await chosen.click();
await expect.element(chosen).toHaveAttribute("aria-checked", "false");
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual(
first24.slice(1),
);
await expect.element(extra).not.toHaveAttribute("aria-disabled", "true");
expect(
document.querySelector('[data-slot="timeline-values-labs-full"]'),
).toBeNull();
});
});
+16 -1
View File
@@ -9,7 +9,9 @@
* not listed again beside it: each one is a named row under the lanes, and
* the menu marks it with its colour. Below the measurements it offers the
* lab markers the record has values for (#1299), under the groups the labs
* list sorts them into; a lab line is chosen and stored like any other.
* list sorts them into; a lab line is chosen and stored like any other, up
* to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are
* held back with a line that says why, and a chosen one can always go.
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -25,6 +27,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
TIMELINE_LAB_SERIES_MAX,
timelineLabMarkerId,
type TimelineLaneKey,
type TimelineResponse,
@@ -61,12 +64,16 @@ export function ValueSeriesMenu({
iconOnly?: boolean;
}) {
const { t } = useTranslations();
const labsFull =
selected.filter((key) => timelineLabMarkerId(key) !== null).length >=
TIMELINE_LAB_SERIES_MAX;
const option = (key: string) => {
const checked = selected.includes(key);
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
disabled={labsFull && !checked && timelineLabMarkerId(key) !== null}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
@@ -121,6 +128,14 @@ export function ValueSeriesMenu({
<DropdownMenuLabel data-slot="timeline-values-labs">
{t("timeline.labs.menuTitle")}
</DropdownMenuLabel>
{labsFull && (
<p
data-slot="timeline-values-labs-full"
className="text-muted-foreground px-2 pb-1 text-xs"
>
{t("timeline.labs.full", { max: TIMELINE_LAB_SERIES_MAX })}
</p>
)}
{labGroups.map((group) => (
<DropdownMenuGroup
key={group.panel ?? ""}
+4 -1
View File
@@ -37,6 +37,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
capTimelineLabKeys,
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
timelineLabMarkerId,
@@ -184,8 +185,10 @@ function useValueLines(
},
[modules],
);
// Lab markers past the cap are not asked for (the server would leave
// them out); the menu holds further ones back.
const values = useMemo(
() => storedValues.filter(allowed),
() => capTimelineLabKeys(storedValues.filter(allowed)),
[storedValues, allowed],
);
// One colour per chosen line, shared by the chart, the selection bar, the
+43 -10
View File
@@ -245,13 +245,21 @@ describe("Codex keeps the working model per sign-in", () => {
await ask(a);
await ask(b);
expect(getCachedCodexSlug(codexCredentialKey.user("a"))).toBe(
"gpt-5.6-terra",
);
expect(getCachedCodexSlug(codexCredentialKey.user("b"))).toBe(
"gpt-6.1-sol",
);
expect(getCachedCodexSlug(codexCredentialKey.central)).toBe("gpt-6.1-sol");
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("a"),
accountId: "pro",
}),
).toBe("gpt-5.6-terra");
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("b"),
accountId: "edu",
}),
).toBe("gpt-6.1-sol");
expect(
getCachedCodexSlug({ key: codexCredentialKey.central, accountId: "edu" }),
).toBe("gpt-6.1-sol");
// Not one refused request: each sign-in only ever asked what it is offered.
expect(log.filter((l) => l.includes("responses"))).toEqual([
"pro responses:gpt-5.6-terra",
@@ -291,6 +299,28 @@ describe("Codex keeps the working model per sign-in", () => {
expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]);
});
it("a credential reconnected to another account tries nothing the old account learned", async () => {
const log = stubBackend({
pro: {
models: planList("gpt-5.6-terra", "gpt-6-sol"),
accepts: ["gpt-5.6-terra", "gpt-6-sol"],
},
// Another plan: terra is neither offered nor accepted.
edu: { models: planList("gpt-6.1-sol"), accepts: ["gpt-6.1-sol"] },
});
const key = codexCredentialKey.user("a");
await ask(client(key, "pro"));
expect(log).toEqual(["pro models", "pro responses:gpt-5.6-terra"]);
// The same stored credential, signed in again on the other account, in
// a process that has not seen the connect (no write has rebound it).
log.length = 0;
const again = client(key, "edu");
await ask(again);
expect(log).toEqual(["edu models", "edu responses:gpt-6.1-sol"]);
expect(again.getLastDiagnostics()).toMatchObject({ cacheState: "miss" });
});
it("the working model that stops answering is dropped and the walk goes on", async () => {
const accepts = ["gpt-5.6-terra", "gpt-6-sol"];
const log = stubBackend({
@@ -298,9 +328,12 @@ describe("Codex keeps the working model per sign-in", () => {
});
const c = client(codexCredentialKey.user("u1"), "plus");
await ask(c);
expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBe(
"gpt-5.6-terra",
);
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("u1"),
accountId: "plus",
}),
).toBe("gpt-5.6-terra");
// terra retires between two requests.
accepts.splice(0, 1);
@@ -251,7 +251,7 @@ describe("readCodexModelCatalog", () => {
const second = await readCodexModelCatalog(PERSONAL, AUTH);
expect(second.from).toBe("cache");
expect(safeFetch).toHaveBeenCalledTimes(1);
expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9);
expect(cachedCodexModels(PERSONAL)).toHaveLength(9);
vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1);
expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live");
@@ -363,8 +363,8 @@ describe("readCodexModelCatalog", () => {
const second = { key: codexCredentialKey.user("u2"), accountId: "acct-b" };
await readCodexModelCatalog(PERSONAL, AUTH);
await readCodexModelCatalog(second, { ...AUTH, accountId: "acct-b" });
expect(cachedCodexModels(PERSONAL.key)).toHaveLength(9);
expect(cachedCodexModels(second.key)?.map((m) => m.slug)).toEqual([
expect(cachedCodexModels(PERSONAL)).toHaveLength(9);
expect(cachedCodexModels(second)?.map((m) => m.slug)).toEqual([
"gpt-6-astra",
"gpt-6.1-sol",
"gpt-6-sol",
@@ -0,0 +1,36 @@
/**
* The routes that connect or disconnect a stored Codex credential tell the
* per-sign-in memory, so the vision gate, which holds no account id, stops
* reading what the previous sign-in learned (`codex-slug-cache.ts`). The
* account-checked client reads cover every other path; this file holds the
* four call sites.
*/
import { describe, expect, it } from "vitest";
import { readSource } from "@/__tests__/helpers/source-files";
describe("Codex sign-in routes", () => {
it("tells the sign-in memory about every connect and disconnect", () => {
const routes: Array<[string, RegExp]> = [
[
"src/app/api/auth/codex/device-poll/route.ts",
/startCodexSignIn\(\{\s*key: codexCredentialKey\.user\(user\.id\),\s*accountId: result\.creds\.accountId/,
],
[
"src/app/api/admin/central-codex/device-poll/route.ts",
/startCodexSignIn\(\{\s*key: codexCredentialKey\.central,\s*accountId: result\.creds\.accountId/,
],
[
"src/app/api/auth/codex/disconnect/route.ts",
/forgetCodexSignIn\(codexCredentialKey\.user\(user\.id\)\)/,
],
[
"src/app/api/admin/central-codex/route.ts",
/forgetCodexSignIn\(codexCredentialKey\.central\)/,
],
];
for (const [file, call] of routes) {
expect(readSource(file, call), file).toMatch(call);
}
});
});
+82 -34
View File
@@ -8,12 +8,15 @@ import {
clearCodexSlugCache,
codexCredentialCacheSize,
codexCredentialKey,
codexGateMemory,
forgetCodexSignIn,
getCachedCodexSlug,
getCodexCatalogState,
rejectedCodexSlugs,
rememberRejectedCodexSlug,
setCachedCodexSlug,
setCodexCatalogState,
startCodexSignIn,
} from "../codex-slug-cache";
/**
@@ -35,19 +38,19 @@ describe("Codex per-sign-in cache", () => {
});
it("starts empty", () => {
expect(getCachedCodexSlug(A.key)).toBeNull();
expect(rejectedCodexSlugs(A.key).size).toBe(0);
expect(getCodexCatalogState(A.key)).toBeNull();
expect(getCachedCodexSlug(A)).toBeNull();
expect(rejectedCodexSlugs(A).size).toBe(0);
expect(getCodexCatalogState(A)).toBeNull();
});
it("holds the working slug for exactly the TTL", () => {
setCachedCodexSlug(A, "gpt-6-sol", 1_000);
expect(getCachedCodexSlug(A.key, 1_000)).toBe("gpt-6-sol");
expect(getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe(
expect(getCachedCodexSlug(A, 1_000)).toBe("gpt-6-sol");
expect(getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe(
"gpt-6-sol",
);
expect(
getCachedCodexSlug(A.key, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1),
getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1),
).toBeNull();
expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000);
});
@@ -57,59 +60,89 @@ describe("Codex per-sign-in cache", () => {
setCachedCodexSlug(A, "gpt-5.6-terra");
setCachedCodexSlug(B, "gpt-6.1-sol");
setCachedCodexSlug(CENTRAL, "gpt-6-sol");
expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra");
expect(getCachedCodexSlug(B.key)).toBe("gpt-6.1-sol");
expect(getCachedCodexSlug(CENTRAL.key)).toBe("gpt-6-sol");
expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra");
expect(getCachedCodexSlug(B)).toBe("gpt-6.1-sol");
expect(getCachedCodexSlug(CENTRAL)).toBe("gpt-6-sol");
rememberRejectedCodexSlug(B, "gpt-6.1-sol");
expect(getCachedCodexSlug(B.key)).toBeNull();
expect(getCachedCodexSlug(A.key)).toBe("gpt-5.6-terra");
expect(rejectedCodexSlugs(A.key).size).toBe(0);
expect(getCachedCodexSlug(B)).toBeNull();
expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra");
expect(rejectedCodexSlugs(A).size).toBe(0);
});
it("a refusal drops the working slot and is held for its own TTL", () => {
setCachedCodexSlug(A, "gpt-5.6-terra", 0);
rememberRejectedCodexSlug(A, "gpt-5.6-terra", 10);
expect(getCachedCodexSlug(A.key, 10)).toBeNull();
expect([...rejectedCodexSlugs(A.key, 10)]).toEqual(["gpt-5.6-terra"]);
expect([
...rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS),
]).toEqual(["gpt-5.6-terra"]);
expect(getCachedCodexSlug(A, 10)).toBeNull();
expect([...rejectedCodexSlugs(A, 10)]).toEqual(["gpt-5.6-terra"]);
expect([...rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS)]).toEqual(
["gpt-5.6-terra"],
);
expect(
rejectedCodexSlugs(A.key, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size,
rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size,
).toBe(0);
});
it("a slug that works again leaves the refused set", () => {
rememberRejectedCodexSlug(A, "gpt-6-sol");
setCachedCodexSlug(A, "gpt-6-sol");
expect(rejectedCodexSlugs(A.key).size).toBe(0);
expect(rejectedCodexSlugs(A).size).toBe(0);
});
it("a credential reconnected to another account starts afresh", () => {
it("a credential reconnected to another account reads as empty before anything is written", () => {
setCachedCodexSlug(A, "gpt-5.6-terra");
rememberRejectedCodexSlug(A, "gpt-6-sol");
setCodexCatalogState(A, { kind: "offered", models: [], at: Date.now() });
// Same stored credential, a different ChatGPT account (another plan).
setCachedCodexSlug({ key: A.key, accountId: "acct-other" }, "gpt-6-luna");
expect(getCachedCodexSlug(A.key)).toBe("gpt-6-luna");
expect(rejectedCodexSlugs(A.key).size).toBe(0);
expect(getCodexCatalogState(A.key)).toBeNull();
// Same stored credential, a different ChatGPT account (another plan):
// its first read finds nothing the old account learned.
const other = { key: A.key, accountId: "acct-other" };
expect(getCachedCodexSlug(other)).toBeNull();
expect(rejectedCodexSlugs(other).size).toBe(0);
expect(getCodexCatalogState(other)).toBeNull();
// Its first write starts the entry afresh, and the old account's memory
// is gone with it.
setCachedCodexSlug(other, "gpt-6-luna");
expect(getCachedCodexSlug(other)).toBe("gpt-6-luna");
expect(rejectedCodexSlugs(other).size).toBe(0);
expect(getCachedCodexSlug(A)).toBeNull();
expect(getCodexCatalogState(A)).toBeNull();
});
it("a connect rebinds the gate's memory and a disconnect forgets it", () => {
setCachedCodexSlug(A, "gpt-5.6-terra");
rememberRejectedCodexSlug(A, "gpt-6-sol");
expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra");
// A reconnect to the same account keeps what it learned.
startCodexSignIn(A);
expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra");
// A reconnect to another account starts it afresh.
startCodexSignIn({ key: A.key, accountId: "acct-other" });
expect(codexGateMemory(A.key)).toEqual({
working: null,
rejected: new Set(),
catalog: null,
});
setCachedCodexSlug(B, "gpt-6-sol");
forgetCodexSignIn(B.key);
expect(codexGateMemory(B.key).working).toBeNull();
expect(getCachedCodexSlug(B)).toBeNull();
});
it("holds a list for its TTL and a failed read for the shorter retry window", () => {
setCodexCatalogState(A, { kind: "offered", models: [], at: 0 });
expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS)?.kind).toBe(
"offered",
);
expect(getCodexCatalogState(A.key, CODEX_CATALOG_TTL_MS + 1)).toBeNull();
expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS)?.kind).toBe("offered");
expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS + 1)).toBeNull();
setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 });
expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS)?.kind).toBe(
expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS)?.kind).toBe(
"unreadable",
);
expect(getCodexCatalogState(B.key, CODEX_CATALOG_RETRY_MS + 1)).toBeNull();
expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS + 1)).toBeNull();
});
it("reports the first unreadable read of a stretch, and re-arms on success", () => {
@@ -135,13 +168,28 @@ describe("Codex per-sign-in cache", () => {
);
}
// Touch the oldest so the second-oldest is the one to go.
expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol");
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("u0"),
accountId: "acct-0",
}),
).toBe("gpt-6-sol");
setCachedCodexSlug(
{ key: codexCredentialKey.user("new"), accountId: "acct-new" },
"gpt-6-sol",
);
expect(codexCredentialCacheSize()).toBe(CODEX_CREDENTIAL_CACHE_MAX);
expect(getCachedCodexSlug(codexCredentialKey.user("u0"))).toBe("gpt-6-sol");
expect(getCachedCodexSlug(codexCredentialKey.user("u1"))).toBeNull();
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("u0"),
accountId: "acct-0",
}),
).toBe("gpt-6-sol");
expect(
getCachedCodexSlug({
key: codexCredentialKey.user("u1"),
accountId: "acct-1",
}),
).toBeNull();
});
});
@@ -26,9 +26,11 @@ import {
import {
clearCodexSlugCache,
codexCredentialKey,
forgetCodexSignIn,
rememberRejectedCodexSlug,
setCachedCodexSlug,
setCodexCatalogState,
startCodexSignIn,
} from "../codex-slug-cache";
const OFFERED = parseCodexModelList(
@@ -278,6 +280,29 @@ describe("vision for a Codex sign-in", () => {
expect(codexSignInReadsImages(B.key)).toBe(true);
});
it("answers from the new sign-in as soon as the credential is connected to another account", () => {
// The old account: its working model listed as text-only.
setCodexCatalogState(A, {
kind: "offered",
models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }],
at: Date.now(),
});
setCachedCodexSlug(A, "gpt-6-sol");
expect(codexSignInReadsImages(A.key)).toBe(false);
// What the device-poll route records on a reconnect.
startCodexSignIn({ key: A.key, accountId: "acct-new" });
expect(resolveCodexVisionSlug(A.key)).toBe(DEFAULT_SLUG_FALLBACK_CHAIN[0]);
expect(codexSignInReadsImages(A.key)).toBe(true);
expect(chainEntryReadsImages("codex", "a", null)).toBe(true);
// And on a disconnect.
setCachedCodexSlug(B, "o3-mini");
expect(codexSignInReadsImages(B.key)).toBe(false);
forgetCodexSignIn(B.key);
expect(codexSignInReadsImages(B.key)).toBe(true);
});
it("falls back to the model family when the list says nothing", () => {
setCodexCatalogState(A, {
kind: "offered",
@@ -137,7 +137,7 @@ describe("Codex slug fallback chain", () => {
"gpt-6-sol",
]);
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol");
expect(getCachedCodexSlug(KEY)).toBe("gpt-6-sol");
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-6-sol");
} finally {
if (origModel === undefined) delete process.env.CODEX_MODEL;
else process.env.CODEX_MODEL = origModel;
@@ -169,7 +169,7 @@ describe("Codex slug fallback chain", () => {
expect(diagnostics?.cacheState).toBe("miss");
expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex");
expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex");
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
});
it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => {
@@ -199,7 +199,7 @@ describe("Codex slug fallback chain", () => {
expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex");
// The working slug — not the rejected one — is now cached.
expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex");
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
});
it("walks past first slug on 400 with model_not_found body", async () => {
@@ -374,7 +374,7 @@ describe("Codex slug fallback chain", () => {
it("cache invalidated when cached slug starts rejecting", async () => {
setCachedCodexSlug(CREDENTIAL, "gpt-5-codex");
expect(getCachedCodexSlug(KEY)).toBe("gpt-5-codex");
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5-codex");
const mockFetch = vi
.fn()
@@ -393,7 +393,7 @@ describe("Codex slug fallback chain", () => {
// After walking to "gpt-5.3-codex" (the next entry after the
// cached one), the cache is updated to the new working slug.
expect(getCachedCodexSlug(KEY)).toBe("gpt-5.3-codex");
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
});
it("isSlugRejection helper recognises canonical rejection bodies", () => {
+1 -1
View File
@@ -297,7 +297,7 @@ export class CodexClient implements AIProvider {
): Promise<CompletionResult> {
// The working slug alone first: the account's list is read only when
// there is none, or when it stops working.
const cached = getCachedCodexSlug(this.selection.credentialKey);
const cached = getCachedCodexSlug(this.credential);
const cacheState = cached === null ? "miss" : "hit";
const loadChain = () =>
loadCodexAttemptChain({
+10 -3
View File
@@ -39,6 +39,7 @@ import {
codexCredentialKey,
getCodexCatalogState,
setCodexCatalogState,
type CodexCatalogState,
type CodexCredential,
} from "./codex-slug-cache";
@@ -228,7 +229,7 @@ export function readCodexModelCatalog(
credential: CodexCredential,
auth: CodexCatalogAuth,
): Promise<CodexCatalogRead> {
const state = getCodexCatalogState(credential.key);
const state = getCodexCatalogState(credential);
if (state?.kind === "offered") {
return Promise.resolve({
models: state.models,
@@ -289,9 +290,15 @@ export function readCodexModelCatalog(
/** The offered models from an earlier read, without touching the network. */
export function cachedCodexModels(
key: string,
credential: CodexCredential,
now: number = Date.now(),
): readonly CodexOfferedModel[] | null {
const state = getCodexCatalogState(key, now);
return offeredModels(getCodexCatalogState(credential, now));
}
/** The models a list read offered, or null when it offered none. */
export function offeredModels(
state: CodexCatalogState | null,
): readonly CodexOfferedModel[] | null {
return state?.kind === "offered" ? state.models : null;
}
+107 -27
View File
@@ -12,10 +12,21 @@
* - `central` for the operator-shared connection (`admin-codex`).
*
* An entry also remembers a digest of the ChatGPT account id it was learned
* under. A credential reconnected to a different account (a different plan)
* starts from an empty entry the first time a client runs on it, so nothing
* learned on the old account carries over. No token, and no raw account id,
* is ever a key or a value here.
* under, and a client reads it only under that account: a credential
* reconnected to a different account (a different plan) reads as empty from
* its first request, before anything is written under the new account, so no
* slug, refusal or list learned on the old account carries over. That holds
* in every process, including one that never saw the reconnect.
*
* The vision gate is the one reader without an account id (the provider
* presence probe decrypts nothing), so it reads the entry as this process last
* bound it ({@link codexGateMemory}). The routes that connect or disconnect a
* credential rebind or drop the entry ({@link startCodexSignIn},
* {@link forgetCodexSignIn}), so in the process that handled the change the
* gate answers from the new sign-in at once; elsewhere it catches up with that
* process's first request on the new sign-in, and a wrong guess in between is
* what the runtime probe and the OCR fallback already cover. No token, and no
* raw account id, is ever a key or a value here.
*
* Bounded both ways: every part of an entry expires on its own TTL, and the
* map holds at most `CODEX_CREDENTIAL_CACHE_MAX` sign-ins, the least recently
@@ -76,7 +87,7 @@ function accountDigest(accountId: string): string {
.slice(0, 32);
}
/** Read an entry without binding it to an account; LRU-touches a hit. */
/** Read an entry whatever account it was learned under; LRU-touches a hit. */
function peek(key: string): CredentialEntry | undefined {
const entry = entries.get(key);
if (!entry) return undefined;
@@ -109,12 +120,22 @@ function bind(credential: CodexCredential): CredentialEntry {
return fresh;
}
/** The model that last worked for this sign-in, while it is fresh. */
export function getCachedCodexSlug(
key: string,
now: number = Date.now(),
/**
* The entry learned under this sign-in's account, or none: an entry left by
* another account on the same credential reads as empty.
*/
function read(credential: CodexCredential): CredentialEntry | undefined {
const entry = peek(credential.key);
if (!entry) return undefined;
return entry.accountDigest === accountDigest(credential.accountId)
? entry
: undefined;
}
function liveWorking(
entry: CredentialEntry | undefined,
now: number,
): string | null {
const entry = peek(key);
if (!entry?.working) return null;
if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) {
entry.working = null;
@@ -123,6 +144,40 @@ export function getCachedCodexSlug(
return entry.working.slug;
}
function liveRejected(
entry: CredentialEntry | undefined,
now: number,
): ReadonlySet<string> {
if (!entry) return new Set();
for (const [slug, at] of entry.rejected) {
if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug);
}
return new Set(entry.rejected.keys());
}
function liveCatalog(
entry: CredentialEntry | undefined,
now: number,
): CodexCatalogState | null {
const state = entry?.catalog;
if (!entry || !state) return null;
const ttl =
state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS;
if (now - state.at > ttl) {
entry.catalog = null;
return null;
}
return state;
}
/** The model that last worked for this sign-in, while it is fresh. */
export function getCachedCodexSlug(
credential: CodexCredential,
now: number = Date.now(),
): string | null {
return liveWorking(read(credential), now);
}
export function setCachedCodexSlug(
credential: CodexCredential,
slug: string,
@@ -151,32 +206,57 @@ export function rememberRejectedCodexSlug(
/** The models this sign-in was refused recently. */
export function rejectedCodexSlugs(
key: string,
credential: CodexCredential,
now: number = Date.now(),
): ReadonlySet<string> {
const entry = peek(key);
if (!entry) return new Set();
for (const [slug, at] of entry.rejected) {
if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug);
}
return new Set(entry.rejected.keys());
return liveRejected(read(credential), now);
}
/** The last read of the account's model list, while it is still trusted. */
export function getCodexCatalogState(
key: string,
credential: CodexCredential,
now: number = Date.now(),
): CodexCatalogState | null {
return liveCatalog(read(credential), now);
}
/** What the vision gate reasons from for one stored credential. */
export interface CodexGateMemory {
working: string | null;
rejected: ReadonlySet<string>;
catalog: CodexCatalogState | null;
}
/**
* The credential's memory as this process last bound it, for the vision gate
* alone, which holds no account id (see the module header). Every other
* reader goes through the account-checked reads above.
*/
export function codexGateMemory(
key: string,
now: number = Date.now(),
): CodexGateMemory {
const entry = peek(key);
const state = entry?.catalog;
if (!entry || !state) return null;
const ttl =
state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS;
if (now - state.at > ttl) {
entry.catalog = null;
return null;
}
return state;
return {
working: liveWorking(entry, now),
rejected: liveRejected(entry, now),
catalog: liveCatalog(entry, now),
};
}
/**
* A credential has just been connected to `accountId`: a reconnect to another
* account starts the entry afresh, so the vision gate in this process stops
* reading what the old account learned; a reconnect to the same account keeps
* it.
*/
export function startCodexSignIn(credential: CodexCredential): void {
bind(credential);
}
/** A credential has been disconnected: forget what it learned. */
export function forgetCodexSignIn(key: string): void {
entries.delete(key);
}
/**
+8 -8
View File
@@ -8,14 +8,14 @@
*/
import { annotate } from "@/lib/logging/context";
import {
cachedCodexModels,
offeredModels,
readCodexModelCatalog,
type CodexCatalogRead,
type CodexOfferedModel,
} from "./codex-model-catalog";
import {
codexCredentialKey,
getCachedCodexSlug,
codexGateMemory,
rejectedCodexSlugs,
type CodexCredential,
} from "./codex-slug-cache";
@@ -172,7 +172,7 @@ export async function loadCodexAttemptChain(input: {
slugChain?: readonly string[];
readModels?: CodexModelReader;
}): Promise<string[]> {
const rejected = rejectedCodexSlugs(input.credential.key);
const rejected = rejectedCodexSlugs(input.credential);
if (input.slugChain && input.slugChain.length > 0) {
return [
...input.slugChain.filter((s) => !rejected.has(s)),
@@ -218,13 +218,13 @@ export async function loadCodexAttemptChain(input: {
* fallback cover.
*/
export function resolveCodexVisionSlug(credentialKey: string): string | null {
const cached = getCachedCodexSlug(credentialKey);
if (cached) return cached;
const memory = codexGateMemory(credentialKey);
if (memory.working) return memory.working;
const { chain } = buildCodexSlugChain({
offered: operatorReplacedCodexChain()
? null
: cachedCodexModels(credentialKey),
rejected: rejectedCodexSlugs(credentialKey),
: offeredModels(memory.catalog),
rejected: memory.rejected,
});
return chain[0] ?? null;
}
@@ -237,7 +237,7 @@ export function resolveCodexVisionSlug(credentialKey: string): string | null {
export function codexSignInReadsImages(credentialKey: string): boolean {
const slug = resolveCodexVisionSlug(credentialKey);
if (!slug) return false;
const offered = cachedCodexModels(credentialKey)?.find(
const offered = offeredModels(codexGateMemory(credentialKey).catalog)?.find(
(m) => m.slug === slug,
);
if (offered?.inputModalities) {
@@ -20,6 +20,7 @@ import {
rememberReasoningDialect,
resetReasoningDialectCache,
} from "../dialect-cache";
import { learnReasoningFieldRefused, nextAnswerlessStep } from "../answerless";
beforeEach(() => {
resetReasoningDialectCache();
@@ -29,7 +30,7 @@ beforeEach(() => {
describe("reasoning dialect cache", () => {
it("keys on provider, endpoint and model together", () => {
const key = { provider: "local", endpoint: "http://a/v1", model: "m1" };
rememberReasoningDialect(key, "none");
rememberReasoningDialect(key, "none", "refusal");
expect(learnedReasoningDialect(key)).toBe("none");
expect(learnedReasoningDialect({ ...key, model: "m2" })).toBeUndefined();
expect(
@@ -60,12 +61,12 @@ describe("reasoning dialect cache", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
expect(learnedReasoningDialect(key)).toBe(ANSWERS_ONLY_UNREASONED);
vi.advanceTimersByTime(1);
expect(learnedReasoningDialect(key)).toBeUndefined();
rememberReasoningDialect(key, REASONS_REGARDLESS);
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS / 2);
expect(learnedReasoningDialect(key)).toBe(REASONS_REGARDLESS);
expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000);
@@ -75,14 +76,22 @@ describe("reasoning dialect cache", () => {
});
// #1279: at the hour, a gateway that refuses the field paid one refused
// request per expiry, a few a day.
it.each(["none", FIELD_REFUSED_REASONS_REGARDLESS])(
"holds a refused field (%s) for a day, not an hour",
(value) => {
// request per expiry, a few a day. Only a 400 refusing the field teaches
// the day.
it.each([
["none", false],
[FIELD_REFUSED_REASONS_REGARDLESS, true],
] as const)(
"holds a refused field a 400 taught (%s) for a day, not an hour",
(value, reasonedUnasked) => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
rememberReasoningDialect(key, value);
if (reasonedUnasked) {
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
}
learnReasoningFieldRefused(key, "openai", "low");
expect(learnedReasoningDialect(key)).toBe(value);
vi.advanceTimersByTime(DIALECT_TTL_MS * 2);
expect(learnedReasoningDialect(key)).toBe(value);
vi.advanceTimersByTime(FIELD_REFUSED_TTL_MS - DIALECT_TTL_MS * 2 - 1);
@@ -96,12 +105,41 @@ describe("reasoning dialect cache", () => {
},
);
it("keeps a refused field's own day when one reply adds that the model reasons regardless", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
learnReasoningFieldRefused(key, "local", "low");
// Late in the field's day, an answerless reply that reasoned.
const late = FIELD_REFUSED_TTL_MS - 2 * DIALECT_TTL_MS;
vi.advanceTimersByTime(late);
expect(nextAnswerlessStep(key, null, true)).toEqual({
kind: "final",
learnedRegardless: true,
});
expect(learnedReasoningDialect(key)).toBe(
FIELD_REFUSED_REASONS_REGARDLESS,
);
// Not cut to the hour: the field refusal would come back after it.
vi.advanceTimersByTime(DIALECT_TTL_MS + 1);
expect(learnedReasoningDialect(key)).toBe(
FIELD_REFUSED_REASONS_REGARDLESS,
);
// Not a fresh day either: one reply taught it, and the field's day
// ends where the 400 set it.
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
expect(learnedReasoningDialect(key)).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("gives a behavioural lesson that replaces a refused field the hour", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
rememberReasoningDialect(key, "none");
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, "none", "refusal");
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS);
expect(learnedReasoningDialect(key)).toBeUndefined();
} finally {
+4 -3
View File
@@ -71,18 +71,18 @@ export function nextAnswerlessStep(
// The endpoint refuses the field: there is no `none` to ask with.
if (learned === "none") {
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS);
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS, "reply");
return { kind: "final", learnedRegardless: true };
}
if (sent === "none") {
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
rememberReasoningDialect(key, REASONS_REGARDLESS);
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
return { kind: "final", learnedRegardless: true };
}
if (!hadReasoning && sent === null) {
return { kind: "final", learnedRegardless: false };
}
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
return { kind: "ask_unreasoned" };
}
@@ -212,6 +212,7 @@ export function learnReasoningFieldRefused(
rememberReasoningDialect(
key,
reasonsUnasked(key) ? FIELD_REFUSED_REASONS_REGARDLESS : "none",
"refusal",
);
annotateReasoningDowngrade(provider, sent, "none");
// One line per lesson, not per call: the operator learns that the
+44 -16
View File
@@ -1,7 +1,7 @@
/**
* v1.41 — what an endpoint answered when it was asked to reason, learned at
* runtime and kept for an hour (`DIALECT_TTL_MS`), a refused reasoning field
* for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the
* runtime and kept for an hour (`DIALECT_TTL_MS`), a reasoning field a 400
* refused for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the
* parameter, or a downgrade learned from a misread error, heals without a
* restart.
*
@@ -78,14 +78,15 @@ export function refusesReasoningField(learned: string | undefined): boolean {
export const DIALECT_TTL_MS = 60 * 60 * 1000;
/**
* #1279 — how long a refused reasoning field holds (`refusesReasoningField`).
* A gateway that refuses the field does so by its configuration (LiteLLM
* without `drop_params`), which changes when its operator changes it, not
* between two calls, and only a 400 naming the field with refusal wording
* can teach it. At the hour every expiry cost one refused request, a few a
* day on a gateway that runs the briefings unattended. A day bounds that to
* one per day, and a gateway that gains the field still gets it the next day.
* The other lessons read a single reply and keep the hour.
* #1279 — how long a reasoning field refused by a 400 holds
* (`refusesReasoningField`). A gateway that refuses the field does so by its
* configuration (LiteLLM without `drop_params`), which changes when its
* operator changes it, not between two calls, and only a 400 naming the
* field with refusal wording can teach it. At the hour every expiry cost one
* refused request, a few a day on a gateway that runs the briefings
* unattended. A day bounds that to one per day, and a gateway that gains the
* field still gets it the next day. A lesson read from a single reply keeps
* the hour.
*/
export const FIELD_REFUSED_TTL_MS = 24 * 60 * 60 * 1000;
@@ -122,18 +123,45 @@ export function learnedReasoningDialect(
}
/**
* Record what `key` accepts after it refused a richer request. A refused
* field holds for `FIELD_REFUSED_TTL_MS`, every other lesson for
* What taught a lesson: a 400 that refused a parameter the client sent, or a
* single reply (one that came back without an answer).
*/
export type ReasoningLessonSource = "refusal" | "reply";
/**
* Record what `key` accepts, by default after a 400 refused a richer request;
* a lesson read from a reply says so (`"reply"`). A reasoning field a 400
* refused holds for `FIELD_REFUSED_TTL_MS`, every other lesson for
* `DIALECT_TTL_MS`.
*
* `FIELD_REFUSED_REASONS_REGARDLESS` carries two facts, and a reply only ever
* adds the second ("reasons regardless") to a field refusal a 400 taught
* earlier. That upgrade keeps the refusal's remaining lifetime: a fresh day
* would let one reply extend what the 400 taught, and the hour would bring
* the refused request back after it. The "regardless" half rides along and
* is learned again from the next answerless reply if it lapses.
*/
export function rememberReasoningDialect(
key: ReasoningDialectKey,
value: string,
source: ReasoningLessonSource = "refusal",
): void {
const ttl = refusesReasoningField(value)
? FIELD_REFUSED_TTL_MS
: DIALECT_TTL_MS;
learned.set(cacheKey(key), { value, expiresAt: Date.now() + ttl });
const k = cacheKey(key);
const now = Date.now();
let expiresAt = now + DIALECT_TTL_MS;
if (refusesReasoningField(value)) {
const before = learned.get(k);
if (source === "refusal") {
expiresAt = now + FIELD_REFUSED_TTL_MS;
} else if (
before &&
before.expiresAt > now &&
refusesReasoningField(before.value)
) {
expiresAt = before.expiresAt;
}
}
learned.set(k, { value, expiresAt });
}
/**
+16 -1
View File
@@ -437,7 +437,7 @@ export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
export const TIMELINE_LAB_KEY_PREFIX = "lab:";
/**
* The most lab markers one request names. Measurement names are a closed
* The most lab markers one request draws. Measurement names are a closed
* set and bound themselves; marker ids are not.
*/
export const TIMELINE_LAB_SERIES_MAX = 24;
@@ -449,6 +449,21 @@ export function timelineLabMarkerId(key: string): string | null {
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
}
/**
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left
* out, everything else in its place. The server applies it to what it is
* asked for and the page to what it asks, so a stored list longer than the
* cap (another device, an older build) loads its first markers instead of
* failing the page.
*/
export function capTimelineLabKeys(keys: readonly string[]): string[] {
let labs = 0;
return keys.filter(
(key) =>
timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX,
);
}
/** The `values` entry for a lab marker. */
export function timelineLabKey(biomarkerId: string): string {
return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`;
+1 -1
View File
@@ -370,7 +370,7 @@ export const timelineQuerySchema = z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left out of `labSeries` without an error, so a stored choice never breaks the page.",
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.",
),
})
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
@@ -0,0 +1,66 @@
/**
* Whose word a restored AI endpoint is taken on. An admin's own copy is the
* operator's configuration, so the operator's wider endpoint policy applies;
* a copy uploaded into an account other than the one that wrote it is the
* word of whoever wrote it, whatever the receiving account's role.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { restoreAccountSettings } from "@/lib/export/account-settings-restore";
const PRIVATE_ENDPOINT = "http://10.20.30.40:11434";
function adminTx() {
const update = vi.fn(async () => ({}));
const tx = {
user: {
findUniqueOrThrow: vi.fn(async () => ({
role: "ADMIN",
aiProvider: null,
unitPreference: "metric",
glucoseUnit: "mg/dL",
})),
update,
},
} as unknown as Prisma.TransactionClient;
return { tx, update };
}
const payload = {
accountSettings: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
};
afterEach(() => {
vi.unstubAllEnvs();
});
describe("restoring an admin account's settings", () => {
it("takes a private AI endpoint from the admin's own copy under the operator's policy", async () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { tx, update } = adminTx();
const result = await restoreAccountSettings(tx, "admin-1", payload, {
retargeted: false,
});
expect(result.refused).toEqual([]);
expect(update).toHaveBeenCalledWith({
where: { id: "admin-1" },
data: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
});
});
it("refuses it from a copy that was uploaded into the account from someone else's file", async () => {
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { tx, update } = adminTx();
const result = await restoreAccountSettings(tx, "admin-1", payload, {
retargeted: true,
});
expect(result.refused).toEqual(["aiBaseUrl"]);
expect(update).toHaveBeenCalledWith({
where: { id: "admin-1" },
data: { aiProvider: "LOCAL" },
});
});
});
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
import {
classifyRestoreFailure,
isPrimaryKeyConflict,
RESTORE_FAILURE_CAUSE_MESSAGES,
RESTORE_FAILURE_CAUSES,
} from "@/lib/export/restore-failure-cause";
@@ -147,3 +148,43 @@ describe("classifyRestoreFailure", () => {
}
});
});
describe("isPrimaryKeyConflict", () => {
it("names a duplicate primary key, as the driver reports it", () => {
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23505", {
kind: "UniqueConstraintViolation",
constraint: { index: "measurements_pkey" },
}),
),
).toBe(true);
// Without the structured name, read from the message.
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23505", {
originalMessage:
'duplicate key value violates unique constraint "mood_entries_pkey"',
}),
),
).toBe(true);
});
it("does not name another unique rule, or another failure", () => {
expect(
isPrimaryKeyConflict(
adapterError("P2002", "23505", {
constraint: { index: "measurements_user_id_external_id_key" },
}),
),
).toBe(false);
expect(
isPrimaryKeyConflict(
adapterError("P2010", "23503", {
constraint: { index: "measurements_pkey" },
}),
),
).toBe(false);
expect(isPrimaryKeyConflict(new Error("measurements_pkey"))).toBe(false);
});
});
@@ -7,17 +7,26 @@
*
* Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first
* case names it (no `userId`); add an owned model to it and the second does.
* Add a model with many rows per account (`Measurement`) to the setup list
* and the third does.
*/
import { describe, expect, it } from "vitest";
import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape";
import { readFileSync } from "node:fs";
import {
parseWipeSchema,
WIPE_SCHEMA_PATH,
} from "@/__tests__/helpers/wipe-schema-shape";
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
import {
BACKED_UP_CHILD_MODELS,
RESTORE_TARGET_OWNED_MODELS,
RESTORE_TARGET_SETUP_MODELS,
} from "@/lib/export/restore-target";
const models = parseWipeSchema();
const SCHEMA = readFileSync(WIPE_SCHEMA_PATH, "utf8");
describe("restore target models", () => {
it("reads every owned backed-up model by its userId", () => {
@@ -42,4 +51,30 @@ describe("restore target models", () => {
expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent);
}
});
it("leaves out only one-row-per-account setup state that no other backed-up model hangs off", () => {
const setup = Object.keys(RESTORE_TARGET_SETUP_MODELS);
expect(setup.sort()).toEqual(["CycleProfile", "OnboardingRecord"]);
for (const model of setup) {
expect(RESTORE_TARGET_OWNED_MODELS, model).toContain(model);
// One row per account: a list of things the person recorded is not
// setup state.
const body = SCHEMA.match(
new RegExp(`^model ${model} \\{([\\s\\S]*?)^\\}`, "m"),
)?.[1];
expect(
/^\s*userId\s+String\s+@unique/m.test(body ?? "") ||
/@@unique\(\[userId\]\)/.test(body ?? ""),
model,
).toBe(true);
// Nothing backed up hangs off it, so leaving it out of the check
// leaves no record unchecked behind it.
for (const other of BACKED_UP_MODELS) {
expect(
models.get(other)?.relations.some((r) => r.target === model),
`${other} -> ${model}`,
).not.toBe(true);
}
}
});
});
+15 -1
View File
@@ -15,6 +15,15 @@ export interface AccountSettingsRestoreInput {
accountSettings?: AccountSettingsBackupEntry | null;
}
/** Where the copy being restored came from. */
export interface AccountSettingsRestoreSource {
/**
* The copy was uploaded into this account from a file another account
* wrote (`backup-upload-type.ts`), so its settings are that author's word.
*/
retargeted: boolean;
}
export interface AccountSettingsRestoreResult {
/** How many setting columns were written. */
applied: number;
@@ -32,11 +41,16 @@ export interface AccountSettingsRestoreResult {
* the row are never in the write, so they stay exactly as the receiving
* account has them. A file without the section leaves the row untouched,
* which is how every backup written before the section existed restores.
*
* An admin's own copy is the operator's configuration and is read under the
* operator's AI endpoint policy. A retargeted copy never is: the admin chose
* where it went, but the file's author wrote its settings.
*/
export async function restoreAccountSettings(
tx: Prisma.TransactionClient,
ownerId: string,
payload: AccountSettingsRestoreInput,
source: AccountSettingsRestoreSource,
): Promise<AccountSettingsRestoreResult> {
const entry = payload.accountSettings;
if (!entry) return { applied: 0, refused: [], unitsChanged: false };
@@ -52,7 +66,7 @@ export async function restoreAccountSettings(
});
const { data, refused } = admitAccountSettings(
entry,
{ operatorTrusted: current.role === "ADMIN" },
{ operatorTrusted: !source.retargeted && current.role === "ADMIN" },
current.aiProvider,
);
+1
View File
@@ -190,6 +190,7 @@ export const RESTORE_STEPS: readonly RestoreStep[] = [
ctx.tx,
ctx.ownerId,
ctx.payload,
{ retargeted: ctx.retargeted },
);
for (const column of result.refused) {
ctx.skips.push({ catalogue: "accountSetting", key: column, links: 1 });
+23
View File
@@ -0,0 +1,23 @@
/**
* The `DataBackup.type` of a copy an admin uploaded.
*
* A file uploaded for the account it names is `MANUAL_UPLOAD_<ms>`. A file
* uploaded into another account (the copy of an erased account, put into the
* account chosen for it) is `RETARGETED_UPLOAD_<ms>`, and the mark stays with
* the stored copy: its settings were written by the file's author, not by the
* account it now belongs to, so its restore never takes them on the
* operator's word, whatever that account's role is by then. The millisecond
* suffix keeps the `(userId, type)` unique constraint free for several
* uploads beside the rolling `WEEKLY_AUTO` copy.
*/
const MANUAL_UPLOAD_PREFIX = "MANUAL_UPLOAD_";
const RETARGETED_UPLOAD_PREFIX = "RETARGETED_UPLOAD_";
export function uploadedBackupType(retargeted: boolean, now: number): string {
return `${retargeted ? RETARGETED_UPLOAD_PREFIX : MANUAL_UPLOAD_PREFIX}${now}`;
}
/** Whether a stored copy was uploaded into an account other than its own. */
export function isRetargetedUploadType(type: string): boolean {
return type.startsWith(RETARGETED_UPLOAD_PREFIX);
}
+33 -1
View File
@@ -31,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit";
import { invalidateUserData } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore";
import { isRetargetedUploadType } from "@/lib/export/backup-upload-type";
import {
RESTORE_CLEAR_STEPS,
RESTORE_STEPS,
@@ -48,6 +49,7 @@ import {
import {
classifyRestoreFailure,
RESTORE_FAILURE_CAUSE_MESSAGES,
isPrimaryKeyConflict,
} from "@/lib/export/restore-failure-cause";
import { takeRestoreLock } from "@/lib/export/restore-lock";
import {
@@ -134,6 +136,9 @@ export type RestoreProgressSink = (
* before the first delete or rolls the whole transaction back, so each means
* the account is unchanged.
*/
/** A retargeted copy whose rows another account already holds. */
const ROWS_EXIST_ELSEWHERE_CODE = "backup.restore.rows_exist_elsewhere";
export type RestoreFailureCode =
| "backup_not_found"
| "backup_changed"
@@ -146,6 +151,7 @@ export type RestoreFailureCode =
| "document_ciphertext_missing"
| "backup.key.missing"
| "backup.foreign_reference"
| "backup.restore.rows_exist_elsewhere"
| "time_budget"
| "transaction_failed"
| "interrupted"
@@ -259,6 +265,7 @@ async function replaceAccount(
read: ReadBackup,
ownerId: string,
restoreInstanceSettings: boolean,
retargeted: boolean,
transactionTimeoutMs: number,
tracker: RestoreProgressTracker,
prepass: MeasurementRestorePrepass,
@@ -285,6 +292,7 @@ async function replaceAccount(
streamed: read.streamed,
skips,
restoreInstanceSettings,
retargeted,
measurementPrepass: prepass,
links: emptyRestoreLinks(),
outputs: {
@@ -342,6 +350,7 @@ async function transactionRefusal(
input: RestoreBackupInput,
ownerId: string,
transactionTimeoutMs: number,
retargeted: boolean,
err: unknown,
): Promise<RestoreBackupOutcome> {
const verbose = err instanceof Error ? err.message : String(err);
@@ -372,6 +381,16 @@ async function transactionRefusal(
references: err.edges,
});
}
if (retargeted && isPrimaryKeyConflict(err)) {
// Rows keep their ids from the file, so the copy of an erased account
// can live in one account only. Rolled back, so nothing was changed.
return refused(
409,
ROWS_EXIST_ELSEWHERE_CODE,
"Records in this copy are already in this database, so the file was most likely already restored into another account. A file can be restored into one account only. Nothing was changed.",
{ errorCode: ROWS_EXIST_ELSEWHERE_CODE },
);
}
if (isStoredBackupReadError(err)) {
// The second read, inside the transaction, found the copy replaced or
// altered. The transaction rolled back, so nothing was changed.
@@ -592,6 +611,12 @@ export async function restoreBackup(
const checked = await checkBackupBeforeRestore(input, read);
if (!checked.ok) return checked.outcome;
const { owner, transactionTimeoutMs } = checked;
// Read from the row, not passed in, so no caller can leave it out.
const stored = await prisma.dataBackup.findUnique({
where: { id: backup.id },
select: { type: true },
});
const retargeted = isRetargetedUploadType(stored?.type ?? "");
let result: TransactionResult;
try {
@@ -599,12 +624,19 @@ export async function restoreBackup(
read,
owner.id,
restoreInstanceSettings,
retargeted,
transactionTimeoutMs,
tracker,
prepass,
);
} catch (err) {
return transactionRefusal(input, owner.id, transactionTimeoutMs, err);
return transactionRefusal(
input,
owner.id,
transactionTimeoutMs,
retargeted,
err,
);
}
const response = await afterCommit(input, read, owner, result, tracker);
+5
View File
@@ -179,6 +179,11 @@ export interface RestoreContext {
/** Declared inside the transaction, so a rollback takes the report with it. */
readonly skips: RestoreSkipLog;
readonly restoreInstanceSettings: boolean;
/**
* The copy was uploaded into this account from another account's file
* (`backup-upload-type.ts`); its settings are not the operator's word.
*/
readonly retargeted: boolean;
/** What the file's first read noted about its readings. */
readonly measurementPrepass: MeasurementRestorePrepass;
readonly links: RestoreLinks;
+19
View File
@@ -108,6 +108,25 @@ export function classifyRestoreFailure(err: unknown): ClassifiedRestoreFailure {
return { cause: "other", code: prisma };
}
/**
* Whether a restore failed because a row it inserted carries a primary key
* already taken. The restore deletes the receiving account's own rows first,
* so the key belongs to a row of another account: for a copy put into a
* chosen account, the same file restored into a different account before.
*/
export function isPrimaryKeyConflict(err: unknown): boolean {
if (sqlState(err) !== "23505") return false;
const cause = field(field(field(err, "meta"), "driverAdapterError"), "cause");
const index = field(field(cause, "constraint"), "index");
const name =
typeof index === "string"
? index
: /unique constraint "([^"]+)"/.exec(
String(field(cause, "originalMessage") ?? field(err, "message")),
)?.[1];
return typeof name === "string" && name.endsWith("_pkey");
}
/** The sentence the job row and the audit trail carry for each cause. */
export const RESTORE_FAILURE_CAUSE_MESSAGES: Record<
RestoreFailureCause,
+56 -7
View File
@@ -8,14 +8,22 @@
* that account holds none of those rows, and the check is exactly that: a row
* of any model the backup carries ({@link BACKED_UP_MODELS}) under the
* account refuses it. Everything a restore leaves alone (credentials,
* sessions, devices, grants) does not count.
* sessions, devices, grants) does not count, and neither does the setup state
* signing up leaves behind ({@link RESTORE_TARGET_SETUP_MODELS}): the person
* did not enter it as a record, and the restore replaces it with the copy's.
* A reading or a medication entered during setup is a record like any other
* and still refuses, because the restore would delete it.
*
* The other two refusals keep the account rules intact. A managed profile is
* The other refusals keep the account rules intact. A managed profile is
* its Guardians' record, which an admin does not write into (the users list
* offers no admin deletion on one for the same reason). An account pending
* deletion is locked and will be erased, with whatever was restored into it.
* Neither the restore nor this check touches the role, so the last-admin rule
* is not in play.
* An admin account is the operator's: its settings are taken on the
* operator's word (a wider AI endpoint policy) and its role reaches every
* account, so another person's record does not go into it. Someone who has
* signed up again holds an ordinary account, and a new one can always be
* made. Neither the restore nor this check touches the role, so the
* last-admin rule is not in play.
*/
import type { Prisma } from "@/generated/prisma/client";
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
@@ -47,6 +55,29 @@ export const BACKED_UP_CHILD_MODELS: Readonly<Record<string, string>> = {
export const RESTORE_TARGET_OWNED_MODELS: readonly string[] =
BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS));
/**
* The backed-up models an account holds a row of after signing up and going
* through setup, before anything has been recorded, each with the writer.
* One row per account, settings-shaped, and deleted and re-created from the
* copy by every restore, so the copy's state replaces it and nothing the
* person entered is lost. Without this list the account of someone who has
* signed up again was always refused, which is the case the target exists
* for. `restore-target-models.test.ts` holds each to that shape.
*/
export const RESTORE_TARGET_SETUP_MODELS: Readonly<Record<string, string>> = {
// `PATCH /api/onboarding/answers` and `POST /api/onboarding/complete`.
OnboardingRecord: "the setup answers",
// Created by the setup confirm when the cycle area is chosen, and by the
// first read of the cycle settings.
CycleProfile: "the cycle settings",
};
/** The models whose rows make an account a refused restore target. */
const RESTORE_TARGET_CHECKED_MODELS: readonly string[] =
RESTORE_TARGET_OWNED_MODELS.filter(
(model) => !(model in RESTORE_TARGET_SETUP_MODELS),
);
interface OwnedDelegate {
findFirst(args: {
where: { userId: string };
@@ -65,13 +96,16 @@ function ownedDelegate(db: object, model: string): OwnedDelegate {
return delegate;
}
/** The backed-up models under which `userId` holds at least one row. */
/**
* The backed-up models under which `userId` holds at least one row, setup
* state left out.
*/
export async function modelsHoldingRecords(
db: Prisma.TransactionClient,
userId: string,
): Promise<string[]> {
const held: string[] = [];
for (const model of RESTORE_TARGET_OWNED_MODELS) {
for (const model of RESTORE_TARGET_CHECKED_MODELS) {
const row = await ownedDelegate(db, model).findFirst({
where: { userId },
select: { userId: true },
@@ -82,7 +116,11 @@ export async function modelsHoldingRecords(
}
export type RestoreTargetRefusal =
"not_found" | "managed_profile" | "pending_deletion" | "holds_records";
| "not_found"
| "managed_profile"
| "pending_deletion"
| "admin_account"
| "holds_records";
export type RestoreTargetVerdict =
| { ok: true; target: { id: string; username: string } }
@@ -106,6 +144,7 @@ export async function checkRestoreTarget(
username: true,
managedProfileAt: true,
deletionRequestedAt: true,
role: true,
},
});
if (!row) {
@@ -118,6 +157,9 @@ export async function checkRestoreTarget(
if (row.deletionRequestedAt) {
return { ok: false, reason: "pending_deletion", target, models: [] };
}
if (row.role === "ADMIN") {
return { ok: false, reason: "admin_account", target, models: [] };
}
const models = await modelsHoldingRecords(db, row.id);
if (models.length > 0) {
return { ok: false, reason: "holds_records", target, models };
@@ -130,6 +172,7 @@ export const BACKUP_TARGET_REFUSAL_CODES = {
not_found: "backup.target.not_found",
managed_profile: "backup.target.managed_profile",
pending_deletion: "backup.target.pending_deletion",
admin_account: "backup.target.admin_account",
holds_records: "backup.target.holds_records",
} as const satisfies Record<RestoreTargetRefusal, string>;
@@ -157,6 +200,12 @@ export const RESTORE_TARGET_REFUSALS: Readonly<
message:
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
},
admin_account: {
status: 409,
code: BACKUP_TARGET_REFUSAL_CODES.admin_account,
message:
"The chosen account is an admin account. A copy of another person's record cannot be restored into it; choose an ordinary account or create a new one.",
},
holds_records: {
status: 409,
code: BACKUP_TARGET_REFUSAL_CODES.holds_records,
@@ -39,6 +39,7 @@ const CODE_TABLE: Record<BackupRestoreFailureCode, true> = {
document_ciphertext_missing: true,
"backup.key.missing": true,
"backup.foreign_reference": true,
"backup.restore.rows_exist_elsewhere": true,
time_budget: true,
transaction_failed: true,
interrupted: true,
+2
View File
@@ -104,7 +104,9 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"backup.owner.missing",
"backup.payload.undecryptable",
"backup.restore.active",
"backup.restore.rows_exist_elsewhere",
"backup.section.missing",
"backup.target.admin_account",
"backup.target.holds_records",
"backup.target.managed_profile",
"backup.target.not_found",
+2 -2
View File
@@ -96,7 +96,7 @@ const restoreFailure = z
.object({
code: z.string().meta({
description:
"Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.",
"Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account already holds, because the same file was restored there before), `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.",
}),
message: z.string(),
sections: z.array(z.string()).optional(),
@@ -287,7 +287,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"409": {
description:
"The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).",
"The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin account (`backup.target.admin_account`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
+13 -2
View File
@@ -272,12 +272,23 @@ describe("a lab marker in `values`", () => {
expect(parseSeriesKeys("lab:../etc")).toBeNull();
});
it("is capped, because marker ids are not a closed set", () => {
it("keeps the first markers up to the cap and leaves the rest out, so a long stored list still loads", () => {
const many = (n: number) =>
Array.from({ length: n }, (_, i) => `lab:m${i}`).join(",");
expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX))).toHaveLength(
TIMELINE_LAB_SERIES_MAX,
);
expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX + 1))).toBeNull();
const capped = parseSeriesKeys(
`WEIGHT,${many(TIMELINE_LAB_SERIES_MAX + 6)},MOOD`,
);
expect(capped).toEqual([
"WEIGHT",
...Array.from({ length: TIMELINE_LAB_SERIES_MAX }, (_, i) => `lab:m${i}`),
"MOOD",
]);
// A malformed key past the cap still refuses: the cap is not a filter.
expect(
parseSeriesKeys(`${many(TIMELINE_LAB_SERIES_MAX)},lab:../x`),
).toBeNull();
});
});
+6 -2
View File
@@ -280,7 +280,11 @@ export async function loadLabSeries(args: {
/**
* Every marker the record has a numeric reading for, whatever the window,
* by name. A marker with only qualitative readings ("negative") has nothing
* to draw and is not offered.
* to draw and is not offered, and neither is a hidden one: hiding a marker
* takes it out of the labs catalogue and the entry pickers, and this picker
* follows. A hidden marker already chosen keeps its line (`loadLabSeries`
* does not filter), and the menu names it from that line, so it can still
* be unticked, as the entry form keeps a hidden marker already selected.
*/
export async function loadAvailableLabMarkers(args: {
recordId: string;
@@ -302,7 +306,7 @@ export async function loadAvailableLabMarkers(args: {
.filter((id): id is string => id !== null);
if (ids.length === 0) return [];
const markers = await prisma.biomarker.findMany({
where: { userId: recordId, id: { in: ids } },
where: { userId: recordId, id: { in: ids }, hidden: false },
select: { id: true, name: true, panel: true, unit: true },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
+7 -8
View File
@@ -17,8 +17,8 @@ import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums";
import type { MeasurementType } from "@/generated/prisma/enums";
import {
capTimelineLabKeys,
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LAB_SERIES_MAX,
TIMELINE_LANE_KEYS,
timelineLabMarkerId,
type TimelineBucket,
@@ -118,7 +118,10 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
* lanes. A lab marker (`lab:<biomarkerId>`, #1299) is checked for its shape
* only: whether the record has it is the loader's question, and the answer
* to a stale one is no line rather than an error. Marker ids are not a
* closed set, so their count is capped.
* closed set, so their count is capped: the first
* `TIMELINE_LAB_SERIES_MAX` are kept and the rest left out. A refusal would
* be stored with the choice and fail the page on every visit, with the menu
* that could undo it never drawn.
*/
export function parseSeriesKeys(raw: string | undefined): string[] | null {
if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES];
@@ -130,15 +133,11 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
.filter((k) => k.length > 0),
),
];
let labs = 0;
for (const key of keys) {
if (timelineLabMarkerId(key) !== null) {
labs += 1;
continue;
}
if (timelineLabMarkerId(key) !== null) continue;
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return labs > TIMELINE_LAB_SERIES_MAX ? null : keys;
return capTimelineLabKeys(keys);
}
/**
@@ -22,11 +22,16 @@
* empty account (typed confirmation) and into a new account it creates,
* every section comes back, the audit trail says what happened, and
* nothing names the erased account, the refused first upload included.
* 6. A target that holds records, is a managed profile, is pending deletion
* or is not confirmed is refused before the file is read; so is a target
* for a file whose own account is still here, and a new account made for
* a refused file is removed again. A non-admin session and a Bearer
* token are refused outright.
* An account that has just been through the setup routes counts as
* empty, and the copy's setup state replaces its own; one whose setup
* ended on a reading of its own does not.
* 6. A target that holds records, is a managed profile, is pending
* deletion, is an admin or is not confirmed is refused before the file
* is read; so is a target for a file whose own account is still here,
* and a new account made for a refused file is removed again. A
* non-admin session and a Bearer token are refused outright. A copy put
* into a chosen account keeps that mark, and its restore takes no
* setting on the operator's word, even after the account is promoted.
*
* Everything runs through production code: the deletion route, the purge job,
* the admin upload, target-check and restore routes and the restore job
@@ -290,6 +295,52 @@ async function checkTarget(userId: string) {
};
}
function jsonRequest(path: string, method: string, body: unknown): Request {
return new Request(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/**
* `userId` signs up again and goes through setup the way the flow does it:
* every question answered, the units written, cycle tracking asked for, and
* the confirm screen's profile fields. The session is left on `userId`.
*/
async function goThroughOnboarding(prisma: PrismaClient, userId: string) {
cookieJar.clear();
await signIn(prisma, userId);
const { PATCH } = await import("@/app/api/onboarding/answers/route");
const answer = async (body: unknown) => {
const res = await (PATCH as (r: Request) => Promise<Response>)(
jsonRequest("/api/onboarding/answers", "PATCH", body),
);
expect(res.status, await res.clone().text()).toBe(200);
};
await answer({ step: "who", recordTarget: "me" });
await answer({ step: "areas", areas: ["blood-pressure", "cycle", "labs"] });
await answer({ step: "medication", medication: "yes" });
await answer({ step: "sources", status: "skipped" });
await answer({ step: "visit", visit: "no" });
await answer({
step: "units",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
});
const { POST } = await import("@/app/api/onboarding/complete/route");
const done = await (POST as (r: Request) => Promise<Response>)(
jsonRequest("/api/onboarding/complete", "POST", {
displayName: "Returning",
heightCm: 172,
dateOfBirth: "1980-05-04",
gender: "FEMALE",
}),
);
expect(done.status, await done.clone().text()).toBe(200);
await answer({ step: "first-result", status: "skipped" });
await answer({ step: "done" });
}
/** The stored copy as the restore will read it, as text. */
async function storedCopyText(prisma: PrismaClient, backupId: string) {
const row = await prisma.dataBackup.findUniqueOrThrow({
@@ -453,6 +504,133 @@ describe("restoring the backup of an erased account into a chosen account", () =
SLOW,
);
it(
"into an account that has just been through setup: the setup state does not count, and the copy's replaces it",
async () => {
const prisma = getPrismaClient();
const { admin, json, counts } = await eraseAfterExport(prisma);
const person = await prisma.user.create({
data: {
username: "returning-person",
email: "returning-person@example.test",
},
});
await goThroughOnboarding(prisma, person.id);
// What setup left behind: its own row, and the cycle profile the cycle
// answer switched on.
expect(
await prisma.onboardingRecord.count({ where: { userId: person.id } }),
).toBe(1);
expect(
await prisma.cycleProfile.findUniqueOrThrow({
where: { userId: person.id },
select: { cycleTrackingEnabled: true },
}),
).toEqual({ cycleTrackingEnabled: true });
cookieJar.clear();
await signIn(prisma, admin.id);
const check = await checkTarget(person.id);
expect(check.body.data).toMatchObject({
eligible: true,
reason: null,
models: [],
});
const stored = await uploadInto(json, {
into: "existing",
userId: person.id,
confirm: "returning-person",
});
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
const restored = await restore(stored.body.data!.id);
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
// Replaced, not merged: the counts are the file's, setup rows included.
expect(await countsOf(prisma, person.id)).toEqual(counts);
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
},
SLOW,
);
it(
"restores a retargeted copy's settings on the file author's word, even into an account promoted to admin since",
async () => {
const prisma = getPrismaClient();
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
const { json } = await eraseAfterExport(prisma);
const payload = JSON.parse(json) as {
accountSettings: Record<string, unknown> | null;
};
payload.accountSettings = {
...(payload.accountSettings ?? {}),
aiProvider: "LOCAL",
aiBaseUrl: "http://10.20.30.40:11434",
};
const person = await prisma.user.create({
data: { username: "returning-person" },
});
const stored = await uploadInto(JSON.stringify(payload), {
into: "existing",
userId: person.id,
confirm: "returning-person",
});
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
const row = await prisma.dataBackup.findUniqueOrThrow({
where: { id: stored.body.data!.id },
select: { type: true },
});
expect(row.type).toMatch(/^RETARGETED_UPLOAD_\d+$/);
// Promoted between the upload and the restore.
await prisma.user.update({
where: { id: person.id },
data: { role: "ADMIN" },
});
const restored = await restore(stored.body.data!.id);
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
expect(
await prisma.user.findUniqueOrThrow({
where: { id: person.id },
select: { aiProvider: true, aiBaseUrl: true },
}),
).toEqual({ aiProvider: "LOCAL", aiBaseUrl: null });
},
SLOW,
);
it(
"refuses an account whose setup ended on a reading of its own, because the restore would delete it",
async () => {
const prisma = getPrismaClient();
const { admin } = await eraseAfterExport(prisma);
const person = await prisma.user.create({
data: { username: "logged-one", email: "logged-one@example.test" },
});
await goThroughOnboarding(prisma, person.id);
// The first-result step's reading goes through the ordinary write.
await prisma.measurement.create({
data: {
userId: person.id,
type: "BLOOD_PRESSURE_SYS",
value: 128,
unit: "mmHg",
measuredAt: new Date("2026-10-28T08:00:00.000Z"),
},
});
cookieJar.clear();
await signIn(prisma, admin.id);
expect((await checkTarget(person.id)).body.data).toMatchObject({
eligible: false,
reason: "holds_records",
models: ["Measurement"],
});
},
SLOW,
);
it(
"into a new account: created without a password, filled, and signed into through the admin reset",
async () => {
@@ -499,17 +677,20 @@ describe("restoring the backup of an erased account into a chosen account", () =
passwordHash: null,
});
// Traced when it is made, before the file is read; the upload row
// links it to the copy.
expect(await auditDetails(prisma, "admin.user.create")).toEqual([
{
targetUserId: account.id,
username: "returning-person",
reason: "backup_restore",
backupId,
outcome: "created",
},
]);
expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([
expect.objectContaining({ ownerId: account.id, into: "new" }),
expect.objectContaining({ backupId, ownerId: account.id, into: "new" }),
]);
expect(await auditDetails(prisma, "admin.user.delete")).toEqual([]);
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
// How the person gets in: the admin sets a first password with the
@@ -566,7 +747,15 @@ describe("restoring the backup of an erased account into a chosen account", () =
},
});
const empty = await prisma.user.create({ data: { username: "empty" } });
const otherAdmin = await prisma.user.create({
data: { username: "second-admin", role: "ADMIN" },
});
expect((await checkTarget(otherAdmin.id)).body.data).toMatchObject({
eligible: false,
reason: "admin_account",
models: [],
});
expect((await checkTarget(used.id)).body.data).toMatchObject({
eligible: false,
reason: "holds_records",
@@ -590,6 +779,11 @@ describe("restoring the backup of an erased account into a chosen account", () =
409,
"backup.target.pending_deletion",
],
[
{ into: "existing", userId: otherAdmin.id, confirm: "second-admin" },
409,
"backup.target.admin_account",
],
[
{ into: "existing", userId: "no-such-account", confirm: "x" },
404,
@@ -625,6 +819,7 @@ describe("restoring the backup of an erased account into a chosen account", () =
"target_holds_records",
"target_managed_profile",
"target_pending_deletion",
"target_admin_account",
"target_not_found",
"target_unconfirmed",
"target_username_taken",
@@ -669,7 +864,24 @@ describe("restoring the backup of an erased account into a chosen account", () =
expect(
await prisma.user.count({ where: { username: "would-be-copy" } }),
).toBe(0);
expect(await auditDetails(prisma, "admin.user.create")).toEqual([]);
// The account was made, traced, and removed again, traced as well.
const made = await auditDetails(prisma, "admin.user.create");
expect(made).toEqual([
{
targetUserId: expect.any(String),
username: "would-be-copy",
reason: "backup_restore",
outcome: "created",
},
]);
expect(await auditDetails(prisma, "admin.user.delete")).toEqual([
{
targetUserId: (made[0] as { targetUserId: string }).targetUserId,
username: "would-be-copy",
reason: "backup_upload_refused",
outcome: "deleted",
},
]);
expect(await prisma.dataBackup.count()).toBe(0);
// Without a target, the same file goes to its own account as before.
@@ -687,6 +899,91 @@ describe("restoring the backup of an erased account into a chosen account", () =
SLOW,
);
it(
"refuses a second restore of the same file into another account, explained and with nothing changed",
async () => {
const prisma = getPrismaClient();
const { json, counts } = await eraseAfterExport(prisma);
const first = await uploadInto(json, {
into: "new",
username: "first-home",
});
expect(first.status, JSON.stringify(first.body)).toBe(201);
expect((await restore(first.body.data!.id)).status).toBe(200);
const home = await prisma.user.findUniqueOrThrow({
where: { username: "first-home" },
});
await prisma.rateLimit.deleteMany();
const second = await uploadInto(json, {
into: "new",
username: "second-home",
});
expect(second.status, JSON.stringify(second.body)).toBe(201);
const again = await restore(second.body.data!.id);
expect(again.status, JSON.stringify(again.body)).toBe(409);
expect(
(again.body as { meta?: { errorCode?: string } }).meta?.errorCode,
).toBe("backup.restore.rows_exist_elsewhere");
expect(again.body.error).toMatch(/already restored into another account/);
// Rolled back: the second account holds nothing, the first all of it.
const twin = await prisma.user.findUniqueOrThrow({
where: { username: "second-home" },
});
expect(
Object.values(await countsOf(prisma, twin.id)).every((n) => n === 0),
).toBe(true);
expect(await countsOf(prisma, home.id)).toEqual(counts);
},
SLOW,
);
it(
"leaves a trace of an account made for a refused file when it cannot be removed again",
async () => {
const prisma = getPrismaClient();
const { json } = await eraseAfterExport(prisma);
await createOwner(prisma);
const { prisma: appPrisma } = await import("@/lib/db");
const remove = vi
.spyOn(appPrisma.user, "delete")
.mockRejectedValueOnce(new Error("connection reset"));
const refused = await uploadInto(json, {
into: "new",
username: "left-behind",
});
remove.mockRestore();
// The operator still sees why the file was refused.
expect(refused.status, JSON.stringify(refused.body)).toBe(409);
expect(refused.body.meta?.errorCode).toBe("backup.owner.exists");
const account = await prisma.user.findUniqueOrThrow({
where: { username: "left-behind" },
});
expect(account).toMatchObject({ passwordHash: null, email: null });
expect(await auditDetails(prisma, "admin.user.create")).toEqual([
{
targetUserId: account.id,
username: "left-behind",
reason: "backup_restore",
outcome: "created",
},
]);
expect(await auditDetails(prisma, "admin.user.delete")).toEqual([
{
targetUserId: account.id,
username: "left-behind",
reason: "backup_upload_refused",
outcome: "failed",
},
]);
},
SLOW,
);
it(
"refuses a non-admin session and an admin's Bearer token, and creates nothing",
async () => {
+1
View File
@@ -30,6 +30,7 @@ const LEGACY_STATUS: Record<string, number> = {
owner_mismatch: 409,
owner_not_found: 422,
"backup.section.missing": 422,
"backup.restore.rows_exist_elsewhere": 409,
document_ciphertext_missing: 422,
time_budget: 503,
transaction_failed: 500,
+46 -3
View File
@@ -287,6 +287,30 @@ describe("lab lines on the timeline", () => {
expect(empty.labSeries[0]).toMatchObject({ points: [], excluded: 0 });
});
it("does not offer a hidden marker, and keeps drawing one already chosen", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);
await getPrismaClient().biomarker.update({
where: { id: marker.id },
data: { hidden: true },
});
await signIn(owner.id);
// Hidden from the picker, as from the labs catalogue and entry pickers.
const offered = await data(await getTimeline(`zoom=all&values=WEIGHT`));
expect(offered.availableLabs).toEqual([]);
// A line chosen before it was hidden still draws, and still names
// itself, so the menu can untick it.
const chosen = await data(
await getTimeline(`zoom=all&values=lab:${marker.id}`),
);
expect(chosen.availableLabs).toEqual([]);
expect(chosen.labSeries.map((line) => [line.key, line.name])).toEqual([
[`lab:${marker.id}`, "Ferritin"],
]);
expect(chosen.labSeries[0].points).toHaveLength(3);
});
it("finds nothing under another record's marker id, and does not refuse", async () => {
const owner = await makeUser("owner");
const other = await makeUser("other");
@@ -299,12 +323,31 @@ describe("lab lines on the timeline", () => {
expect(timeline.labSeries).toEqual([]);
expect(timeline.availableLabs).toEqual([]);
// A list of ids is capped, and a malformed one is refused.
const many = Array.from({ length: 25 }, (_, i) => `lab:m${i}`).join(",");
expect((await getTimeline(`values=${many}`)).status).toBe(422);
// A malformed id is refused.
expect((await getTimeline(`values=lab:..%2Fx`)).status).toBe(422);
});
it("draws the first 24 markers of a longer list and leaves the rest out, rather than refusing", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);
await signIn(owner.id);
const filler = Array.from({ length: 24 }, (_, i) => `lab:m${i}`);
// Past the cap: the record's own marker is the 25th, so it is not drawn.
const past = await getTimeline(
`zoom=all&values=WEIGHT,${[...filler, `lab:${marker.id}`].join(",")}`,
);
expect(past.status).toBe(200);
expect((await data(past)).labSeries).toEqual([]);
// Within the cap it is.
const within = await data(
await getTimeline(
`zoom=all&values=${[...filler.slice(1), `lab:${marker.id}`].join(",")}`,
),
);
expect(within.labSeries.map((s) => s.key)).toEqual([`lab:${marker.id}`]);
});
it("refuses a grant scoped to some sections, and shares the lines on a whole-record grant", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);