mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v1463-review' into release/v1.46.3
This commit is contained in:
commit
1f0b05f141
58 files changed
+1468
-245
No files matched your search
+50
-45
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -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,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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user