mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge remote-tracking branch 'origin/main' into feat/1371-medication-note
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
4b500dc66c
25 files changed
+1199
-157
No files matched your search
@@ -18,6 +18,39 @@
|
||||
out keeps it), and the list and detail reads answer `note`, `null` when
|
||||
there is none. Migration `0498_medication_note`. (#1371)
|
||||
|
||||
- **Measurement tokens can log custom-metric values.** A script or a
|
||||
Home Assistant rule holding a measurement token can now post a value
|
||||
to `POST /api/custom-metrics/{id}/entries` for a custom metric you
|
||||
already created. The token still cannot list, read, create, change or
|
||||
delete custom metrics or their values, and a metric of another account
|
||||
answers 404. A metric's ID is the part after `/custom-metrics/` in the
|
||||
address of its page, and the token card in Settings says so. Values
|
||||
posted this way are not marked as coming from an external device; the
|
||||
audit log records which token wrote them. Measurement tokens you
|
||||
already have can do this too, without being created again. (#1377)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Opening a record on a phone.** In the account menu, "Open a record"
|
||||
used to open a second menu beside the first, and on a narrow screen it
|
||||
ran off the edge and cut the names off. On a phone the records now
|
||||
unfold inside the account menu itself; wider screens keep the side
|
||||
menu. (#1372)
|
||||
- **A clearer message when the browser refuses push.** When the browser's
|
||||
push service turned the registration down, enabling Browser Push said
|
||||
only "Activation failed". It now says that the push service refused and
|
||||
links the help page. Brave keeps push messaging off by default, so in
|
||||
Brave the message names the setting to turn on: "Use Google services
|
||||
for push messaging" under brave://settings/privacy. (Discussion #1378)
|
||||
|
||||
### Security
|
||||
|
||||
- The `hono` dependency floor is raised to 4.13.16 (CVE-2026-126355 to
|
||||
CVE-2026-126358). HealthLog only gets it under the MCP SDK and loads none
|
||||
of the affected parts (CSRF and validator middleware, `mount`, the
|
||||
RegExpRouter), so this clears the audit rather than closing a reachable
|
||||
hole.
|
||||
|
||||
## [1.47.5] — 2026-11-12
|
||||
|
||||
### Fixed
|
||||
|
||||
+26
-11
@@ -3617,21 +3617,24 @@ paths:
|
||||
the only place it ever exists; it is stored as an HMAC and no path re-reveals it.
|
||||
|
||||
|
||||
**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows
|
||||
it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading
|
||||
pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter.
|
||||
A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding
|
||||
it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage:
|
||||
it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what
|
||||
**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, and
|
||||
`POST /api/custom-metrics/{id}/entries` for a custom metric its owner already created (it cannot list, create,
|
||||
edit or delete metrics, so the id comes from the metric's page in the app). Measurement rows it writes carry
|
||||
`source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a
|
||||
bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body
|
||||
naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it
|
||||
silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it
|
||||
is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what
|
||||
decides the Apple Health card may claim a sync happened. For the same reason a write through this credential
|
||||
does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected
|
||||
provider's — the hardware behind the token is the user's own.
|
||||
|
||||
|
||||
**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete
|
||||
legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared
|
||||
record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its
|
||||
holder actually has. And it cannot mint another token, this endpoint included.
|
||||
**What it cannot do.** Everything else, including the measurement reads on the same paths, the custom-metric
|
||||
list and value reads, the edit and delete legs, and the export — a scope grants what it names and nothing
|
||||
adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before
|
||||
any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint
|
||||
included.
|
||||
|
||||
|
||||
Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five
|
||||
@@ -21140,7 +21143,19 @@ paths:
|
||||
tags:
|
||||
- Custom metrics
|
||||
summary: Log a value
|
||||
description: Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.
|
||||
description: >-
|
||||
Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.
|
||||
|
||||
|
||||
**Narrow token.** Also reachable with a `measurements:write` Bearer (minted at `POST /api/tokens/measurements`),
|
||||
the supported way to push a custom metric's value from a script or a home-automation rule. Such a credential
|
||||
writes only to its owner's own live metrics: an id that is unknown, deleted, or another account's answers the
|
||||
same 404, and a request carrying the account selector is refused 403 before any sharing grant is read. This POST
|
||||
is all the scope reaches on custom metrics. The value list on this same path, the metric list, and creating,
|
||||
editing or deleting a metric or a value name no scope and refuse it 403, so the metric id has to come from its
|
||||
owner: it is the path segment after `/custom-metrics/` in the address of the metric's page in the app. The entry
|
||||
carries no source of its own; the audit row names the token that wrote it. Every other narrow token,
|
||||
`workouts:write` included, is refused 403.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Push-Benachrichtigungen aktiviert!",
|
||||
"webPushUnsubscribed": "Push-Benachrichtigungen deaktiviert.",
|
||||
"webPushSubscribeFailed": "Aktivierung fehlgeschlagen",
|
||||
"webPushServiceRefused": "Der Push-Dienst deines Browsers hat die Anmeldung abgelehnt, deshalb ließ sich Push nicht einschalten. Der Browser muss seinen Push-Dienst erreichen, und eine Datenschutzeinstellung, ein Blocker oder das Netzwerk kann das verhindern. Versuche es später noch einmal oder in einem anderen Browser.",
|
||||
"webPushServiceRefusedBrave": "Der Push-Dienst von Brave hat die Anmeldung abgelehnt. Brave lässt Push ab Werk ausgeschaltet: Öffne brave://settings/privacy, schalte dort die Nutzung von Google-Diensten für Push-Nachrichten ein und tippe dann noch einmal auf „Push aktivieren“.",
|
||||
"webPushHelpLink": "Hilfe zu Browser-Push",
|
||||
"codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.",
|
||||
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
|
||||
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Tokens für Messwerte, Trainings, Dokumente und Medikamenten-Einnahme prüfen und widerrufen.",
|
||||
"measurementsToken": {
|
||||
"title": "Messwert-Tokens",
|
||||
"description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Werte eintragen kann.",
|
||||
"description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Messwerte und Werte deiner eigenen Metriken eintragen kann.",
|
||||
"detail": "Das Token wird nur einmal angezeigt.",
|
||||
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.",
|
||||
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen, auch für eigene Metriken, die du bereits angelegt hast. Es kann deine Daten nicht lesen, keine Metrik anlegen, nichts ändern oder löschen und kein weiteres Token erstellen. Messwerte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst; Werte eigener Metriken tragen keine solche Kennzeichnung. Beides lässt sich jederzeit korrigieren. Die ID einer eigenen Metrik ist der Teil nach /custom-metrics/ in der Adresse ihrer Seite.",
|
||||
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||
"namePlaceholder": "Name, z. B. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Push notifications enabled!",
|
||||
"webPushUnsubscribed": "Push notifications disabled.",
|
||||
"webPushSubscribeFailed": "Activation failed",
|
||||
"webPushServiceRefused": "Your browser's push service refused the registration, so push could not be turned on. The browser has to reach its push service, and a privacy setting, a blocker or the network can stop it. Try again later or in another browser.",
|
||||
"webPushServiceRefusedBrave": "Brave's push service refused the registration. Brave keeps push off by default: open brave://settings/privacy, turn on “Use Google services for push messaging” and tap Enable push again.",
|
||||
"webPushHelpLink": "Help with browser push",
|
||||
"codexConnected": "ChatGPT connected successfully! Insights are now active.",
|
||||
"codexDisconnected": "ChatGPT connection removed.",
|
||||
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Review and revoke tokens for measurements, workouts, documents and medication intake.",
|
||||
"measurementsToken": {
|
||||
"title": "Measurement tokens",
|
||||
"description": "Mint a token so a scale, a watch bridge or your home automation can add readings.",
|
||||
"description": "Mint a token so a scale, a watch bridge or your home automation can add readings and custom-metric values.",
|
||||
"detail": "The token is shown once.",
|
||||
"scopeNote": "It can only add new readings to your own record. It cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.",
|
||||
"scopeNote": "It can only add new readings to your own record, and values to custom metrics you already created. It cannot read your data, create a custom metric, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later; custom-metric values carry no such mark. You can still correct both yourself. A custom metric's ID is the part after /custom-metrics/ in the address of its page.",
|
||||
"createFailed": "The token could not be created. Please try again.",
|
||||
"namePlaceholder": "Name, e.g. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "¡Notificaciones push activadas!",
|
||||
"webPushUnsubscribed": "Notificaciones push desactivadas.",
|
||||
"webPushSubscribeFailed": "Falló la activación",
|
||||
"webPushServiceRefused": "El servicio push de tu navegador rechazó el registro, así que no se pudo activar push. El navegador tiene que llegar a su servicio push, y una opción de privacidad, un bloqueador o la red pueden impedirlo. Vuelve a intentarlo más tarde o en otro navegador.",
|
||||
"webPushServiceRefusedBrave": "El servicio push de Brave rechazó el registro. Brave trae push desactivado de fábrica: abre brave://settings/privacy, activa el uso de los servicios de Google para la mensajería push y toca otra vez «Activar push».",
|
||||
"webPushHelpLink": "Ayuda con push en el navegador",
|
||||
"codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.",
|
||||
"codexDisconnected": "Conexión con ChatGPT eliminada.",
|
||||
"codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Revisa y revoca los tokens de mediciones, entrenamientos, documentos y tomas de medicación.",
|
||||
"measurementsToken": {
|
||||
"title": "Tokens de mediciones",
|
||||
"description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones.",
|
||||
"description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones y valores de tus métricas personalizadas.",
|
||||
"detail": "El token se muestra una sola vez.",
|
||||
"scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.",
|
||||
"scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial y valores a las métricas personalizadas que ya creaste: no puede leer tus datos, ni crear una métrica, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde; los valores de las métricas personalizadas no llevan esa marca. Puedes corregir ambos igualmente. El ID de una métrica personalizada es la parte que sigue a /custom-metrics/ en la dirección de su página.",
|
||||
"createFailed": "No se pudo crear el token. Inténtalo de nuevo.",
|
||||
"namePlaceholder": "Nombre, p. ej. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Notifications push activées !",
|
||||
"webPushUnsubscribed": "Notifications push désactivées.",
|
||||
"webPushSubscribeFailed": "Échec de l’activation",
|
||||
"webPushServiceRefused": "Le service push de votre navigateur a refusé l’inscription, le push n’a donc pas pu être activé. Le navigateur doit pouvoir joindre son service push, et un réglage de confidentialité, un bloqueur ou le réseau peut l’en empêcher. Réessayez plus tard ou dans un autre navigateur.",
|
||||
"webPushServiceRefusedBrave": "Le service push de Brave a refusé l’inscription. Brave désactive le push par défaut : ouvrez brave://settings/privacy, activez l’utilisation des services Google pour la messagerie push, puis touchez à nouveau « Activer le push ».",
|
||||
"webPushHelpLink": "Aide sur le push du navigateur",
|
||||
"codexConnected": "ChatGPT connecté ! Insights est maintenant actif.",
|
||||
"codexDisconnected": "Connexion à ChatGPT supprimée.",
|
||||
"codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Consultez et révoquez les jetons des mesures, entraînements, documents et prises de médicaments.",
|
||||
"measurementsToken": {
|
||||
"title": "Jetons de mesures",
|
||||
"description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures.",
|
||||
"description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures et des valeurs à vos mesures personnalisées.",
|
||||
"detail": "Le jeton n'est affiché qu'une fois.",
|
||||
"scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.",
|
||||
"scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier, ainsi que des valeurs aux mesures personnalisées que vous avez déjà créées : il ne peut pas lire vos données, ni créer une mesure personnalisée, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard ; les valeurs des mesures personnalisées ne portent pas cette marque. Vous pouvez toujours corriger les unes comme les autres. L'identifiant d'une mesure personnalisée est la partie qui suit /custom-metrics/ dans l'adresse de sa page.",
|
||||
"createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.",
|
||||
"namePlaceholder": "Nom, p. ex. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Notifiche push attivate!",
|
||||
"webPushUnsubscribed": "Notifiche push disattivate.",
|
||||
"webPushSubscribeFailed": "Attivazione non riuscita",
|
||||
"webPushServiceRefused": "Il servizio push del browser ha rifiutato la registrazione, quindi non è stato possibile attivare il push. Il browser deve raggiungere il proprio servizio push, e un'impostazione sulla privacy, un blocco dei contenuti o la rete possono impedirlo. Riprova più tardi o con un altro browser.",
|
||||
"webPushServiceRefusedBrave": "Il servizio push di Brave ha rifiutato la registrazione. Brave tiene il push disattivato di serie: apri brave://settings/privacy, attiva l'uso dei servizi Google per la messaggistica push e tocca di nuovo «Attiva push».",
|
||||
"webPushHelpLink": "Aiuto sul push del browser",
|
||||
"codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.",
|
||||
"codexDisconnected": "Connessione a ChatGPT rimossa.",
|
||||
"codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Rivedi e revoca i token per misurazioni, allenamenti, documenti e assunzioni di farmaci.",
|
||||
"measurementsToken": {
|
||||
"title": "Token per misurazioni",
|
||||
"description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni.",
|
||||
"description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni e valori delle tue metriche personalizzate.",
|
||||
"detail": "Il token viene mostrato una sola volta.",
|
||||
"scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.",
|
||||
"scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella e valori alle metriche personalizzate che hai già creato: non può leggere i tuoi dati, creare una metrica, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito; i valori delle metriche personalizzate non hanno questo contrassegno. Puoi comunque correggere entrambi. L'ID di una metrica personalizzata è la parte che segue /custom-metrics/ nell'indirizzo della sua pagina.",
|
||||
"createFailed": "Non è stato possibile creare il token. Riprova.",
|
||||
"namePlaceholder": "Nome, ad es. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "푸시 알림을 켰어요!",
|
||||
"webPushUnsubscribed": "푸시 알림을 껐어요.",
|
||||
"webPushSubscribeFailed": "켜지 못했어요",
|
||||
"webPushServiceRefused": "브라우저의 푸시 서비스가 등록을 거부해서 푸시를 켜지 못했어요. 브라우저가 푸시 서비스에 연결할 수 있어야 하는데, 개인정보 보호 설정이나 차단 프로그램, 네트워크가 이를 막을 수 있어요. 나중에 다시 시도하거나 다른 브라우저에서 시도해 주세요.",
|
||||
"webPushServiceRefusedBrave": "Brave의 푸시 서비스가 등록을 거부했어요. Brave는 기본적으로 푸시가 꺼져 있어요. brave://settings/privacy를 열고 푸시 메시지에 Google 서비스를 사용하는 설정을 켠 다음 '푸시 켜기'를 다시 눌러 주세요.",
|
||||
"webPushHelpLink": "브라우저 푸시 도움말",
|
||||
"codexConnected": "ChatGPT를 연결했어요! 이제 인사이트를 쓸 수 있어요.",
|
||||
"codexDisconnected": "ChatGPT 연결을 해제했어요.",
|
||||
"codexConnectionFailed": "ChatGPT 연결에 실패했어요. 다시 시도해 주세요.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "측정값, 운동, 문서, 복용 기록용 토큰을 확인하고 폐기해요.",
|
||||
"measurementsToken": {
|
||||
"title": "측정 토큰",
|
||||
"description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.",
|
||||
"description": "체중계나 시계, 홈 오토메이션이 측정값과 사용자 지표 값을 기록할 수 있도록 토큰을 만들어요.",
|
||||
"detail": "토큰은 한 번만 표시돼요.",
|
||||
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.",
|
||||
"scopeNote": "본인 기록에 새 측정값을 추가하고, 이미 만든 사용자 지표에 값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 사용자 지표를 만들거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되어 나중에 구분할 수 있지만, 사용자 지표 값에는 이런 표시가 없어요. 둘 다 언제든 고칠 수 있어요. 사용자 지표의 ID는 해당 지표 페이지 주소에서 /custom-metrics/ 뒤에 오는 부분이에요.",
|
||||
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
|
||||
"namePlaceholder": "이름 (예: Home Assistant)",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Pushmeldingen ingeschakeld!",
|
||||
"webPushUnsubscribed": "Pushmeldingen uitgeschakeld.",
|
||||
"webPushSubscribeFailed": "Activeren mislukt",
|
||||
"webPushServiceRefused": "De pushdienst van je browser heeft de registratie geweigerd, dus push kon niet worden ingeschakeld. De browser moet zijn pushdienst kunnen bereiken, en een privacyinstelling, een blocker of het netwerk kan dat tegenhouden. Probeer het later opnieuw of in een andere browser.",
|
||||
"webPushServiceRefusedBrave": "De pushdienst van Brave heeft de registratie geweigerd. Brave zet push standaard uit: open brave://settings/privacy, zet het gebruik van Google-services voor pushberichten aan en tik daarna opnieuw op 'Push inschakelen'.",
|
||||
"webPushHelpLink": "Hulp bij browserpush",
|
||||
"codexConnected": "ChatGPT gekoppeld! Inzichten zijn nu actief.",
|
||||
"codexDisconnected": "Koppeling met ChatGPT verwijderd.",
|
||||
"codexConnectionFailed": "Koppelen met ChatGPT is mislukt. Probeer het opnieuw.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Bekijk tokens voor metingen, trainingen, documenten en medicatie-inname en trek ze in.",
|
||||
"measurementsToken": {
|
||||
"title": "Metingtokens",
|
||||
"description": "Maak een token aan zodat een weegschaal, een horlogekoppeling of je domotica metingen kan toevoegen.",
|
||||
"description": "Maak een token aan zodat een weegschaal, een horlogekoppeling of je domotica metingen en waarden voor je eigen metingen kan toevoegen.",
|
||||
"detail": "Het token wordt één keer getoond.",
|
||||
"scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen. Het kan je gegevens niet lezen, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden, en je kunt ze nog steeds zelf corrigeren.",
|
||||
"scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen, en waarden aan eigen metingen die je al hebt aangemaakt. Het kan je gegevens niet lezen, geen eigen meting aanmaken, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden; waarden van eigen metingen krijgen die markering niet. Je kunt beide nog steeds zelf corrigeren. De ID van een eigen meting is het deel na /custom-metrics/ in het adres van de pagina ervan.",
|
||||
"createFailed": "Het token kon niet worden aangemaakt. Probeer het opnieuw.",
|
||||
"namePlaceholder": "Naam, bijv. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Powiadomienia push włączone!",
|
||||
"webPushUnsubscribed": "Powiadomienia push wyłączone.",
|
||||
"webPushSubscribeFailed": "Aktywacja nie powiodła się",
|
||||
"webPushServiceRefused": "Usługa push Twojej przeglądarki odrzuciła rejestrację, więc nie udało się włączyć push. Przeglądarka musi mieć dostęp do swojej usługi push, a może to zablokować ustawienie prywatności, bloker albo sieć. Spróbuj ponownie później albo w innej przeglądarce.",
|
||||
"webPushServiceRefusedBrave": "Usługa push przeglądarki Brave odrzuciła rejestrację. Brave domyślnie ma wyłączony push: otwórz brave://settings/privacy, włącz korzystanie z usług Google do wiadomości push i stuknij ponownie „Włącz push”.",
|
||||
"webPushHelpLink": "Pomoc dotycząca push w przeglądarce",
|
||||
"codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.",
|
||||
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
|
||||
"codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny do pomiarów, treningów, dokumentów i przyjęć leków.",
|
||||
"measurementsToken": {
|
||||
"title": "Tokeny pomiarów",
|
||||
"description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary.",
|
||||
"description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary i wartości Twoich metryk własnych.",
|
||||
"detail": "Token pokazujemy tylko raz.",
|
||||
"scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji: nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.",
|
||||
"scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji oraz wartości do już utworzonych metryk własnych: nie odczyta danych, nie utworzy metryki, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz; wartości metryk własnych nie mają takiego oznaczenia. Jedne i drugie nadal możesz poprawić. Identyfikator metryki własnej to część po /custom-metrics/ w adresie jej strony.",
|
||||
"createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.",
|
||||
"namePlaceholder": "Nazwa, np. Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Notificações push ativadas!",
|
||||
"webPushUnsubscribed": "Notificações push desativadas.",
|
||||
"webPushSubscribeFailed": "Falha na ativação",
|
||||
"webPushServiceRefused": "O serviço de push do seu navegador recusou o registro, então não foi possível ativar o push. O navegador precisa alcançar o serviço de push dele, e uma configuração de privacidade, um bloqueador ou a rede podem impedir isso. Tente de novo mais tarde ou em outro navegador.",
|
||||
"webPushServiceRefusedBrave": "O serviço de push do Brave recusou o registro. O Brave vem com o push desligado: abra brave://settings/privacy, ative o uso dos serviços do Google para mensagens push e toque de novo em “Ativar push”.",
|
||||
"webPushHelpLink": "Ajuda com push no navegador",
|
||||
"codexConnected": "ChatGPT conectado com sucesso! Os insights agora estão ativos.",
|
||||
"codexDisconnected": "Conexão com o ChatGPT removida.",
|
||||
"codexConnectionFailed": "Falha na conexão com o ChatGPT. Tente novamente.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Revise e revogue tokens para medições, treinos, documentos e tomadas de medicamentos.",
|
||||
"measurementsToken": {
|
||||
"title": "Tokens de medição",
|
||||
"description": "Gere um token para que uma balança, uma ponte de relógio ou sua automação residencial possa adicionar leituras.",
|
||||
"description": "Gere um token para que uma balança, uma ponte de relógio ou sua automação residencial possa adicionar leituras e valores das suas métricas personalizadas.",
|
||||
"detail": "O token é exibido uma vez.",
|
||||
"scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro. Não pode ler seus dados, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois, e você ainda pode corrigi-las você mesmo.",
|
||||
"scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro e valores às métricas personalizadas que você já criou. Não pode ler seus dados, criar uma métrica, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois; os valores de métricas personalizadas não recebem essa marca. Você ainda pode corrigir ambos. O ID de uma métrica personalizada é a parte depois de /custom-metrics/ no endereço da página dela.",
|
||||
"createFailed": "O token não pôde ser criado. Tente novamente.",
|
||||
"namePlaceholder": "Nome, ex.: Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
+5
-2
@@ -6434,6 +6434,9 @@
|
||||
"webPushSubscribed": "Push-уведомления включены!",
|
||||
"webPushUnsubscribed": "Push-уведомления выключены.",
|
||||
"webPushSubscribeFailed": "Не удалось активировать",
|
||||
"webPushServiceRefused": "Push-сервис вашего браузера отклонил регистрацию, поэтому push не удалось включить. Браузер должен иметь доступ к своему push-сервису, а этому может помешать настройка конфиденциальности, блокировщик или сеть. Попробуйте позже или в другом браузере.",
|
||||
"webPushServiceRefusedBrave": "Push-сервис Brave отклонил регистрацию. В Brave push по умолчанию выключен: откройте brave://settings/privacy, включите использование сервисов Google для push-сообщений и снова нажмите «Включить push».",
|
||||
"webPushHelpLink": "Справка по push в браузере",
|
||||
"codexConnected": "ChatGPT успешно подключён! Аналитика теперь активна.",
|
||||
"codexDisconnected": "Подключение ChatGPT удалено.",
|
||||
"codexConnectionFailed": "Не удалось подключить ChatGPT. Попробуйте снова.",
|
||||
@@ -6994,9 +6997,9 @@
|
||||
"apiTokensDescription": "Просматривайте и отзывайте токены для измерений, тренировок, документов и приёма лекарств.",
|
||||
"measurementsToken": {
|
||||
"title": "Токены измерений",
|
||||
"description": "Создайте токен, чтобы весы, мост для часов или ваша система умного дома могли добавлять измерения.",
|
||||
"description": "Создайте токен, чтобы весы, мост для часов или ваша система умного дома могли добавлять измерения и значения ваших собственных показателей.",
|
||||
"detail": "Токен показывается один раз.",
|
||||
"scopeNote": "Он может только добавлять новые измерения в вашу собственную историю. Он не может читать ваши данные, изменять или удалять что-либо или создавать другой токен. Измерения помечаются как пришедшие с внешнего устройства, чтобы вы могли отличить их позже, и вы всё равно можете исправить их сами.",
|
||||
"scopeNote": "Он может только добавлять новые измерения в вашу собственную историю и значения в уже созданные вами собственные показатели. Он не может читать ваши данные, создавать показатели, изменять или удалять что-либо или создавать другой токен. Измерения помечаются как пришедшие с внешнего устройства, чтобы вы могли отличить их позже; значения собственных показателей такой пометки не получают. Вы всё равно можете исправить и то, и другое сами. Идентификатор собственного показателя указан в адресе его страницы после /custom-metrics/.",
|
||||
"createFailed": "Не удалось создать токен. Попробуйте снова.",
|
||||
"namePlaceholder": "Название, например Home Assistant",
|
||||
"appleHealth": {
|
||||
|
||||
Generated
+9
-9
@@ -6,7 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
js-yaml@<4.3.2: ^4.3.2
|
||||
hono@<4.13.11: ^4.13.11
|
||||
hono@<4.13.16: ^4.13.16
|
||||
baseline-browser-mapping@<2.11.0: ^2.11.0
|
||||
sharp@<0.35.4: ^0.35.4
|
||||
dompurify@<3.4.17: ^3.4.17
|
||||
@@ -875,7 +875,7 @@ packages:
|
||||
resolution: {integrity: sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
hono: ^4.13.11
|
||||
hono: ^4.13.16
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
@@ -4754,8 +4754,8 @@ packages:
|
||||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
hono@4.13.13:
|
||||
resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==}
|
||||
hono@4.13.18:
|
||||
resolution: {integrity: sha512-H9q/E1XizaWstJxUW881dmLxEj+t/TrGGnyxINkm4+sK0qUwsMtFdo2BWI9Vs3cIS19MU6b6iCijoUoEf4f3DA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
@@ -7999,9 +7999,9 @@ snapshots:
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@hono/node-server@2.1.6(hono@4.13.13)':
|
||||
'@hono/node-server@2.1.6(hono@4.13.18)':
|
||||
dependencies:
|
||||
hono: 4.13.13
|
||||
hono: 4.13.18
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
@@ -8294,7 +8294,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@hono/node-server': 2.1.6(hono@4.13.13)
|
||||
'@hono/node-server': 2.1.6(hono@4.13.18)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
@@ -8304,7 +8304,7 @@ snapshots:
|
||||
eventsource-parser: 3.1.1
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
hono: 4.13.13
|
||||
hono: 4.13.18
|
||||
jose: 6.2.15
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -12043,7 +12043,7 @@ snapshots:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
hono@4.13.13: {}
|
||||
hono@4.13.18: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
overrides:
|
||||
# CVE-2026-84375 (js-yaml < 4.3.2, HIGH), CVE-2026-84363/84364/84365 (hono
|
||||
# < 4.13.5, via @hono/node-server under the MCP SDK; GHSA-5r4p-p66f-jhc7 raised the
|
||||
# floor to 4.13.11), CVE-2026-45819
|
||||
# floor to 4.13.11, CVE-2026-126355 to 126358 to 4.13.16), CVE-2026-45819
|
||||
# (baseline-browser-mapping < 2.11.0), GHSA-rgj7-g3m4-5g8c (sharp < 0.35.4,
|
||||
# pulled by next). None is a direct dependency; the bounds move the copies
|
||||
# the scanner flags and nothing else.
|
||||
"js-yaml@<4.3.2": "^4.3.2"
|
||||
"hono@<4.13.11": "^4.13.11"
|
||||
"hono@<4.13.16": "^4.13.16"
|
||||
"baseline-browser-mapping@<2.11.0": "^2.11.0"
|
||||
"sharp@<0.35.4": "^0.35.4"
|
||||
# GHSA-55q2-fjhq-7xh7: a crafted document gets past the sanitiser and its
|
||||
|
||||
@@ -430,6 +430,49 @@ describe("T4 — declared scopes are exported constants, never string literals",
|
||||
});
|
||||
});
|
||||
|
||||
describe("T4a — `measurements:write` opens exactly three doors", () => {
|
||||
it("the two measurement writes and the custom-metric value write, POST only", () => {
|
||||
// The set of files naming the scope IS its reach. The single and batch
|
||||
// measurement writes, and logging a value against a custom metric the
|
||||
// person already made. Not the measurement reads, not the custom-metric
|
||||
// list or detail, and not creating, editing or deleting a metric: a
|
||||
// fourth file here is a widening and belongs in review.
|
||||
const declaring = sourceFiles().filter((rel) => {
|
||||
const src = read(rel);
|
||||
return (
|
||||
requireAuthScopes(src).includes("MEASUREMENTS_WRITE_SCOPE") ||
|
||||
recordAuthScopes(src).includes("MEASUREMENTS_WRITE_SCOPE")
|
||||
);
|
||||
});
|
||||
expect(declaring).toEqual([
|
||||
"app/api/custom-metrics/[id]/entries/route.ts",
|
||||
"app/api/measurements/batch/route.ts",
|
||||
"app/api/measurements/route.ts",
|
||||
]);
|
||||
|
||||
// Within the custom-metric file, once, and on the POST: the GET beside it
|
||||
// lists the metric's values and must keep refusing the token.
|
||||
const entries = readSource(
|
||||
"src/app/api/custom-metrics/[id]/entries/route.ts",
|
||||
"MEASUREMENTS_WRITE_SCOPE",
|
||||
);
|
||||
expect(
|
||||
requireAuthScopes(entries).filter(
|
||||
(x) => x === "MEASUREMENTS_WRITE_SCOPE",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(recordAuthScopes(entries)).toEqual([]);
|
||||
const postBody = entries.slice(
|
||||
entries.indexOf("async function postCustomMetricEntry"),
|
||||
);
|
||||
expect(
|
||||
requireAuthScopes(postBody).filter(
|
||||
(x) => x === "MEASUREMENTS_WRITE_SCOPE",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T4b — `documents:write` opens exactly two doors", () => {
|
||||
it("only the vault upload and the source-key lookup declare the scope", () => {
|
||||
// The scope admits a narrow token wherever it is named, so the set of
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
isScopedCredential,
|
||||
requireAuth,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -16,6 +21,7 @@ import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import {
|
||||
createCustomMetricEntrySchema,
|
||||
listCustomMetricEntriesSchema,
|
||||
@@ -28,8 +34,9 @@ import {
|
||||
* GET lists the metric's values with offset pagination (the chart + history
|
||||
* feed). POST records a value, snapshotting the metric's current `unit` onto
|
||||
* the entry at write time (historical truth). `userId` is always narrowed from
|
||||
* the session and the parent metric is verified owner-scoped (a forged / foreign
|
||||
* id is a 404) before any value is read or written.
|
||||
* the session (or, on POST, the measurement-ingest token) and the parent metric
|
||||
* is verified owner-scoped (a forged / foreign id is a 404) before any value is
|
||||
* read or written.
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -105,7 +112,27 @@ async function postCustomMetricEntry(
|
||||
// product did not offer. Whoever builds a delegate-reachable way to log a
|
||||
// tracked value adds it back in the same diff: the argument for admitting it
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
//
|
||||
// It does accept the measurement-ingest token (#1377): a custom metric's
|
||||
// value is a reading the person defined themselves, and the bridge that
|
||||
// pushes their weight is the one that would push it. What that admits is
|
||||
// this POST and nothing beside it. The GET above names no scope, nor do the
|
||||
// metric's own create / edit / delete routes, so the token can add a value
|
||||
// to a metric that already exists and cannot list the metrics, read one
|
||||
// back, or make a new one. The id it writes to comes from the person, who
|
||||
// reads it off the metric's page; a token that has only ever been pasted
|
||||
// into a scale's uploader never learns one.
|
||||
//
|
||||
// `requireAuth` rather than the record resolver, so the selector refusal is
|
||||
// the undeclared-mode one every credential meets here: the token is on its
|
||||
// owner's record or nowhere. The ownership query below then turns any id
|
||||
// that is not that owner's live metric into the same 404 an unknown id gets.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
const { user } = auth;
|
||||
// A custom-metric entry has no source column, so the row cannot say a
|
||||
// bridge wrote it the way an `EXTERNAL` measurement does. The audit row
|
||||
// can, and it names the token, so revoking the right one is possible later.
|
||||
const ingestTokenId = isScopedCredential(auth) ? auth.session.id : null;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -156,12 +183,20 @@ async function postCustomMetricEntry(
|
||||
await auditLog("customMetricEntry.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { customMetricId: id, entryId: created.id },
|
||||
details: {
|
||||
customMetricId: id,
|
||||
entryId: created.id,
|
||||
...(ingestTokenId ? { ingestTokenId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "custom-metric.entry.create" },
|
||||
meta: { customMetricId: id, entryId: created.id },
|
||||
meta: {
|
||||
customMetricId: id,
|
||||
entryId: created.id,
|
||||
via_ingest_scope: ingestTokenId !== null,
|
||||
},
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The record switcher inside the account menu, at phone and desktop width
|
||||
* (#1372).
|
||||
*
|
||||
* On a phone the account menu sits against the right edge of the top bar and
|
||||
* takes most of the width. The records used to open in a side sub-menu that
|
||||
* had room on neither side, so it ran off the screen and cut the names off.
|
||||
* Below `sm` they now unfold inside the account menu itself; at desktop width
|
||||
* the side sub-menu stays.
|
||||
*
|
||||
* The harness mounts the switcher the way the top bar does: a right-aligned
|
||||
* trigger, `align="end"`, a `w-60` menu.
|
||||
*
|
||||
* Mutation check, run: forcing the side sub-menu at every width (the
|
||||
* component before the fix) turns "unfolds the records inside the account
|
||||
* menu" red, because the entries land outside the viewport and outside the
|
||||
* account menu.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
|
||||
const switchTo = vi.fn();
|
||||
const mutate = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate, switchTo, isPending: false }),
|
||||
}));
|
||||
|
||||
import { AccountSwitcherMenuItems } from "../account-switcher-menu";
|
||||
|
||||
function entry(
|
||||
accountId: string,
|
||||
fullName: string,
|
||||
recordKind: "shared" | "managed",
|
||||
): AccountAccessEntry {
|
||||
return {
|
||||
accountId,
|
||||
username: accountId,
|
||||
displayName: null,
|
||||
fullName,
|
||||
access: "read",
|
||||
level: "read",
|
||||
sections: null,
|
||||
recordKind,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNTS = [
|
||||
entry("acct-a", "Alexandra Konstantinopoulou-Wetherington", "shared"),
|
||||
entry("acct-b", "Maximilian Oberhausen", "managed"),
|
||||
];
|
||||
|
||||
async function mountMenu() {
|
||||
await renderWithApp(
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Account</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<AccountSwitcherMenuItems />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>,
|
||||
{
|
||||
user: {
|
||||
accountAccess: { accounts: ACCOUNTS, active: null, canSwitch: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
await page.getByRole("button", { name: "Account" }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
document.querySelector('[data-slot="account-switcher-trigger"]'),
|
||||
)
|
||||
.toBeTruthy();
|
||||
}
|
||||
|
||||
function trigger(): HTMLElement {
|
||||
return document.querySelector<HTMLElement>(
|
||||
'[data-slot="account-switcher-trigger"]',
|
||||
)!;
|
||||
}
|
||||
|
||||
function entries(): HTMLElement[] {
|
||||
return [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
'[data-slot="account-switcher-entry"]',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
switchTo.mockClear();
|
||||
mutate.mockClear();
|
||||
});
|
||||
|
||||
describe("the record switcher at phone width", () => {
|
||||
it("unfolds the records inside the account menu", async () => {
|
||||
await page.viewport(390, 844);
|
||||
await mountMenu();
|
||||
|
||||
await userEvent.click(trigger());
|
||||
await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
|
||||
|
||||
const accountMenu = trigger().closest('[role="menu"]');
|
||||
for (const row of entries()) {
|
||||
// In the account menu, not in a menu of its own beside it.
|
||||
expect(row.closest('[role="menu"]')).toBe(accountMenu);
|
||||
const box = row.getBoundingClientRect();
|
||||
expect(box.left).toBeGreaterThanOrEqual(0);
|
||||
expect(box.right).toBeLessThanOrEqual(window.innerWidth);
|
||||
// A finger-sized row.
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1);
|
||||
expect(trigger().getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps the account menu open while the list unfolds and folds", async () => {
|
||||
await page.viewport(390, 844);
|
||||
await mountMenu();
|
||||
|
||||
expect(entries()).toHaveLength(0);
|
||||
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(trigger());
|
||||
await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
|
||||
|
||||
await userEvent.click(trigger());
|
||||
await expect.poll(() => entries().length).toBe(0);
|
||||
// The account menu itself is still there.
|
||||
expect(trigger()).toBeTruthy();
|
||||
expect(trigger().getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("opens the chosen record from the inline list", async () => {
|
||||
await page.viewport(390, 844);
|
||||
await mountMenu();
|
||||
|
||||
await userEvent.click(trigger());
|
||||
await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
|
||||
await userEvent.click(
|
||||
document.querySelector<HTMLElement>(
|
||||
'[data-slot="account-switcher-entry"][data-account-id="acct-b"]',
|
||||
)!,
|
||||
);
|
||||
|
||||
expect(switchTo).toHaveBeenCalledTimes(1);
|
||||
expect(switchTo.mock.calls[0][0]).toBe("acct-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the record switcher at desktop width", () => {
|
||||
it("keeps the side sub-menu", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
await mountMenu();
|
||||
|
||||
expect(trigger().hasAttribute("data-presentation")).toBe(false);
|
||||
await userEvent.click(trigger());
|
||||
await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
|
||||
|
||||
const accountMenu = trigger().closest('[role="menu"]');
|
||||
for (const row of entries()) {
|
||||
const menu = row.closest('[role="menu"]');
|
||||
expect(menu).toBeTruthy();
|
||||
expect(menu).not.toBe(accountMenu);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Users } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Check, ChevronDown, Loader2, Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
@@ -11,10 +13,15 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccess,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the switcher, as a slice of the user menu.
|
||||
@@ -35,16 +42,83 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
* "Your own record" is a first-class entry rather than only a banner action:
|
||||
* the way out has to be reachable from the same menu as the way in, because
|
||||
* that is where somebody will look for it.
|
||||
*
|
||||
* Below the `sm` breakpoint the records open inside the account menu rather
|
||||
* than in a sub-menu beside it. The phone's account menu sits against the
|
||||
* right edge and takes most of the width; a 240 px sub-menu has room on
|
||||
* neither side, and Radix only shifts a side menu along its own axis, so it
|
||||
* ran off the screen and cut the names off. The trigger keeps its slot and the
|
||||
* list keeps `account-switcher-menu`, so a journey that taps the trigger and
|
||||
* then an entry reads the same at either width.
|
||||
*/
|
||||
export function AccountSwitcherMenuItems() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const switchAccount = useAccountSwitch();
|
||||
const inline = useIsMobile("sm");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const listId = useId();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
if (!access?.canSwitch) return null;
|
||||
|
||||
const activeId = access.active?.accountId ?? null;
|
||||
const label = t("recordSharing.switcher.label");
|
||||
const icon = switchAccount.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Users className="size-4" aria-hidden="true" />
|
||||
);
|
||||
const rows = (
|
||||
<RecordRows
|
||||
access={access}
|
||||
activeId={activeId}
|
||||
inset={inline}
|
||||
switchAccount={switchAccount}
|
||||
/>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-trigger"
|
||||
data-presentation="inline"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? listId : undefined}
|
||||
className="cursor-pointer"
|
||||
// Keep the account menu open: the trigger unfolds the list in place.
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
setExpanded((open) => !open);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className="ml-2">{label}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"ml-auto size-4 transition-transform motion-reduce:transition-none",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
{expanded && (
|
||||
<DropdownMenuGroup
|
||||
id={listId}
|
||||
data-slot="account-switcher-menu"
|
||||
aria-label={label}
|
||||
>
|
||||
{rows}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,72 +128,14 @@ export function AccountSwitcherMenuItems() {
|
||||
data-slot="account-switcher-trigger"
|
||||
className="min-h-11 py-2 sm:min-h-9"
|
||||
>
|
||||
{switchAccount.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Users className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="ml-2">{t("recordSharing.switcher.label")}</span>
|
||||
{icon}
|
||||
<span className="ml-2">{label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="account-switcher-menu"
|
||||
className="max-h-(--radix-dropdown-menu-content-available-height) w-60 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-own"
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("recordSharing.switcher.ownRecord")}
|
||||
</span>
|
||||
{activeId === null && (
|
||||
<span className="text-primary ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{access.accounts.map((account) => {
|
||||
const presentation = resolveRecordPresentation(account);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
data-account-username={account.username}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
account.accountId,
|
||||
sharedRecordLandingHref(account.sections),
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Two lines, not three. The access level had a line of its
|
||||
own here and it never fit — a 240 px menu truncated
|
||||
"Kann verwalten" as often as it showed it, and the entry
|
||||
still said which kind of record this is on the line below.
|
||||
The level is what the banner states once you are inside,
|
||||
where it governs what the page lets you do. It stays on
|
||||
the row as `data-access-level` for the browser journeys. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{rows}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
@@ -134,3 +150,81 @@ function recordKindLabel(
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows themselves: your own record first, then every record the server
|
||||
* published. The same rows in the side sub-menu and in the inline list; the
|
||||
* inline list indents them under the trigger (`inset`).
|
||||
*/
|
||||
function RecordRows({
|
||||
access,
|
||||
activeId,
|
||||
inset,
|
||||
switchAccount,
|
||||
}: {
|
||||
access: AccountAccess;
|
||||
activeId: string | null;
|
||||
inset: boolean;
|
||||
switchAccount: ReturnType<typeof useAccountSwitch>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-own"
|
||||
inset={inset}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("recordSharing.switcher.ownRecord")}
|
||||
</span>
|
||||
{activeId === null && (
|
||||
<span className="text-primary ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{access.accounts.map((account) => {
|
||||
const presentation = resolveRecordPresentation(account);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
data-account-username={account.username}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
inset={inset}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
account.accountId,
|
||||
sharedRecordLandingHref(account.sections),
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Two lines, not three. The access level had a line of its
|
||||
own here and it never fit — a 240 px menu truncated
|
||||
"Kann verwalten" as often as it showed it, and the entry
|
||||
still said which kind of record this is on the line below.
|
||||
The level is what the banner states once you are inside,
|
||||
where it governs what the page lets you do. It stays on
|
||||
the row as `data-access-level` for the browser journeys. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
|
||||
import { WebPushCard } from "../web-push-card";
|
||||
import { WEB_PUSH_DOCS_HREF, WebPushCard } from "../web-push-card";
|
||||
|
||||
const SUBSCRIPTION = {
|
||||
endpoint: "https://push.example.test/send/abc",
|
||||
@@ -62,3 +62,69 @@ describe("WebPushCard", () => {
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A browser whose push service refuses the registration, as Brave does with
|
||||
* push messaging switched off: `subscribe()` rejects with an `AbortError`.
|
||||
*/
|
||||
function refuseAtPushService(): void {
|
||||
vi.spyOn(Notification, "permission", "get").mockReturnValue("granted");
|
||||
vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
const refusal = new DOMException(
|
||||
"Registration failed - push service error",
|
||||
"AbortError",
|
||||
);
|
||||
vi.spyOn(navigator.serviceWorker, "ready", "get").mockReturnValue(
|
||||
Promise.resolve({
|
||||
pushManager: { subscribe: () => Promise.reject(refusal) },
|
||||
} as unknown as ServiceWorkerRegistration),
|
||||
);
|
||||
}
|
||||
|
||||
function alertText(): string {
|
||||
return document.querySelector('[role="alert"]')?.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("WebPushCard when the push service refuses", () => {
|
||||
afterEach(() => {
|
||||
delete (navigator as Navigator & { brave?: unknown }).brave;
|
||||
});
|
||||
|
||||
it("says the push service refused and links the docs", async () => {
|
||||
refuseAtPushService();
|
||||
const api = stubFetch().on("GET", "/api/notifications/vapid", {
|
||||
data: { publicKey: "BAAAAAAA" },
|
||||
});
|
||||
|
||||
const { screen } = await renderWithApp(<WebPushCard />);
|
||||
await screen.getByRole("button", { name: "Enable push" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => alertText())
|
||||
.toContain("Your browser's push service refused the registration");
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Help with browser push" }))
|
||||
.toHaveAttribute("href", WEB_PUSH_DOCS_HREF);
|
||||
// Nothing reached the server: the browser refused before a save.
|
||||
expect(api.calls("POST", "/api/notifications/web-push")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("names the setting in Brave", async () => {
|
||||
refuseAtPushService();
|
||||
Object.defineProperty(navigator, "brave", {
|
||||
value: { isBrave: async () => true },
|
||||
configurable: true,
|
||||
});
|
||||
stubFetch().on("GET", "/api/notifications/vapid", {
|
||||
data: { publicKey: "BAAAAAAA" },
|
||||
});
|
||||
|
||||
const { screen } = await renderWithApp(<WebPushCard />);
|
||||
await screen.getByRole("button", { name: "Enable push" }).click();
|
||||
|
||||
await expect.poll(() => alertText()).toContain("brave://settings/privacy");
|
||||
expect(alertText()).toContain("Use Google services for push messaging");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { BellRing, ExternalLink, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { DOCS_GUIDES_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -14,6 +16,14 @@ import {
|
||||
saveWebPushSubscription,
|
||||
unsubscribeWebPush,
|
||||
} from "@/lib/notifications/web-push-subscription";
|
||||
import {
|
||||
classifyWebPushSubscribeFailure,
|
||||
isBraveBrowser,
|
||||
type WebPushSubscribeFailure,
|
||||
} from "@/lib/notifications/web-push-subscribe-failure";
|
||||
|
||||
/** The docs section on Browser Push, linked when the browser refused. */
|
||||
export const WEB_PUSH_DOCS_HREF = `${DOCS_GUIDES_BASE}/reminder-channels/#web-push-vapid`;
|
||||
|
||||
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
|
||||
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
|
||||
@@ -43,6 +53,67 @@ export function needsHomeScreen(): boolean {
|
||||
|
||||
type Unsupported = "browser" | "install" | null;
|
||||
|
||||
/** What one press of "Enable push" ended in. */
|
||||
type SubscribeOutcome =
|
||||
"subscribed" | "not-configured" | "save-failed" | WebPushSubscribeFailure;
|
||||
|
||||
/**
|
||||
* Subscribe this browser and store the subscription on the server. The
|
||||
* browser's own refusal is classified apart from the server's answers around
|
||||
* it: Brave, for one, rejects with "push service error" until push messaging
|
||||
* is switched on in its privacy settings, and "Activation failed" told nobody
|
||||
* that.
|
||||
*/
|
||||
async function subscribeThisBrowser(): Promise<SubscribeOutcome> {
|
||||
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
|
||||
if (!vapidRes.ok) return "not-configured";
|
||||
const vapidData = await vapidRes.json();
|
||||
const vapidPublicKey = vapidData.data.publicKey;
|
||||
|
||||
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in the
|
||||
// root providers) rather than re-registering here — same scope, so a second
|
||||
// `register("/sw.js")` would only return the existing worker. `ready`
|
||||
// resolves with the active registration once it is controlling the page.
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
let subscription: PushSubscription;
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
|
||||
});
|
||||
} catch (error) {
|
||||
return classifyWebPushSubscribeFailure(error, {
|
||||
permission: Notification.permission,
|
||||
isBrave: isBraveBrowser(),
|
||||
});
|
||||
}
|
||||
|
||||
const res = await saveWebPushSubscription(subscription);
|
||||
return res.ok ? "subscribed" : "save-failed";
|
||||
}
|
||||
|
||||
function subscribeOutcomeMessage(
|
||||
outcome: SubscribeOutcome,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
switch (outcome) {
|
||||
case "subscribed":
|
||||
return t("settings.webPushSubscribed");
|
||||
case "not-configured":
|
||||
return t("settings.webPushNotConfigured");
|
||||
case "denied":
|
||||
return t("settings.webPushDenied");
|
||||
case "push-service":
|
||||
return t("settings.webPushServiceRefused");
|
||||
case "push-service-brave":
|
||||
return t("settings.webPushServiceRefusedBrave");
|
||||
case "save-failed":
|
||||
case "unknown":
|
||||
return t("settings.webPushSubscribeFailed");
|
||||
}
|
||||
}
|
||||
|
||||
export function WebPushCard() {
|
||||
const { t } = useTranslations();
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
@@ -52,6 +123,7 @@ export function WebPushCard() {
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Async I/O (browser API + service worker registration). The lint rule
|
||||
@@ -95,50 +167,21 @@ export function WebPushCard() {
|
||||
setActionLoading(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
setShowHelp(false);
|
||||
|
||||
let outcome: SubscribeOutcome;
|
||||
try {
|
||||
const vapidRes = await apiFetchRaw("/api/notifications/vapid");
|
||||
if (!vapidRes.ok) {
|
||||
setMsg(t("settings.webPushNotConfigured"));
|
||||
setMsgType("error");
|
||||
setActionLoading(false);
|
||||
return;
|
||||
}
|
||||
const vapidData = await vapidRes.json();
|
||||
const vapidPublicKey = vapidData.data.publicKey;
|
||||
|
||||
// Defer to the app-wide registration (`<ServiceWorkerRegistrar>` in
|
||||
// the root providers) rather than re-registering here — same scope,
|
||||
// so a second `register("/sw.js")` would only return the existing
|
||||
// worker. `ready` resolves with the active registration once it is
|
||||
// controlling the page.
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
|
||||
});
|
||||
|
||||
const res = await saveWebPushSubscription(subscription);
|
||||
if (res.ok) {
|
||||
setIsSubscribed(true);
|
||||
setMsg(t("settings.webPushSubscribed"));
|
||||
setMsgType("success");
|
||||
} else {
|
||||
setMsg(t("settings.webPushSubscribeFailed"));
|
||||
setMsgType("error");
|
||||
}
|
||||
outcome = await subscribeThisBrowser();
|
||||
} catch {
|
||||
if (Notification.permission === "denied") {
|
||||
setIsDenied(true);
|
||||
setMsg(t("settings.webPushDenied"));
|
||||
} else {
|
||||
setMsg(t("settings.webPushSubscribeFailed"));
|
||||
}
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
outcome = Notification.permission === "denied" ? "denied" : "unknown";
|
||||
}
|
||||
if (outcome === "subscribed") setIsSubscribed(true);
|
||||
if (outcome === "denied") setIsDenied(true);
|
||||
setMsg(subscribeOutcomeMessage(outcome, t));
|
||||
setMsgType(outcome === "subscribed" ? "success" : "error");
|
||||
// The push service refused: the fix is in the browser, so link the docs.
|
||||
setShowHelp(outcome === "push-service" || outcome === "push-service-brave");
|
||||
setActionLoading(false);
|
||||
}
|
||||
|
||||
async function handleUnsubscribe() {
|
||||
@@ -232,6 +275,18 @@ export function WebPushCard() {
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
{msg && showHelp && (
|
||||
<InlineAction asChild tone="primary" size="sm">
|
||||
<a
|
||||
href={WEB_PUSH_DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("settings.webPushHelpLink")}
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</a>
|
||||
</InlineAction>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* How the Browser Push card reads a refusal from `pushManager.subscribe()`.
|
||||
*
|
||||
* The reported case is Brave with push messaging switched off, which rejects
|
||||
* with `AbortError: Registration failed - push service error` and used to
|
||||
* show only "Activation failed".
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyWebPushSubscribeFailure,
|
||||
isBraveBrowser,
|
||||
} from "../web-push-subscribe-failure";
|
||||
|
||||
function domError(name: string, message: string): Error {
|
||||
const error = new Error(message);
|
||||
error.name = name;
|
||||
return error;
|
||||
}
|
||||
|
||||
const PUSH_SERVICE_ERROR = domError(
|
||||
"AbortError",
|
||||
"Registration failed - push service error",
|
||||
);
|
||||
|
||||
describe("classifyWebPushSubscribeFailure", () => {
|
||||
it("names the push service when the browser could not register with it", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
|
||||
permission: "granted",
|
||||
isBrave: false,
|
||||
}),
|
||||
).toBe("push-service");
|
||||
});
|
||||
|
||||
it("names Brave's setting when the browser is Brave", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
|
||||
permission: "granted",
|
||||
isBrave: true,
|
||||
}),
|
||||
).toBe("push-service-brave");
|
||||
});
|
||||
|
||||
it("reads any other AbortError from subscribe as the push service", () => {
|
||||
// Chromium also says "push service not available"; Firefox says
|
||||
// "Error retrieving push subscription." Both are the push service.
|
||||
for (const message of [
|
||||
"Registration failed - push service not available",
|
||||
"Error retrieving push subscription.",
|
||||
]) {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(domError("AbortError", message), {
|
||||
permission: "granted",
|
||||
isBrave: false,
|
||||
}),
|
||||
).toBe("push-service");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a blocked permission as blocked, whatever the error said", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
|
||||
permission: "denied",
|
||||
isBrave: true,
|
||||
}),
|
||||
).toBe("denied");
|
||||
});
|
||||
|
||||
it("does not blame the push service for a dismissed permission prompt", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(
|
||||
domError("NotAllowedError", "Registration failed - permission denied"),
|
||||
{ permission: "default", isBrave: true },
|
||||
),
|
||||
).toBe("unknown");
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(
|
||||
domError("AbortError", "Registration failed - permission denied"),
|
||||
{ permission: "default", isBrave: true },
|
||||
),
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not blame the push service for a key the browser would not take", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(
|
||||
domError(
|
||||
"AbortError",
|
||||
"Registration failed - missing applicationServerKey, and manifest empty or missing",
|
||||
),
|
||||
{ permission: "granted", isBrave: true },
|
||||
),
|
||||
).toBe("unknown");
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(
|
||||
domError(
|
||||
"InvalidAccessError",
|
||||
"The provided applicationServerKey is not valid.",
|
||||
),
|
||||
{ permission: "granted", isBrave: false },
|
||||
),
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("keeps the generic message for anything that is not an error object", () => {
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure("boom", {
|
||||
permission: "granted",
|
||||
isBrave: false,
|
||||
}),
|
||||
).toBe("unknown");
|
||||
expect(
|
||||
classifyWebPushSubscribeFailure(new TypeError("Failed to fetch"), {
|
||||
permission: "granted",
|
||||
isBrave: false,
|
||||
}),
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBraveBrowser", () => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
|
||||
afterEach(() => {
|
||||
if (original) Object.defineProperty(globalThis, "navigator", original);
|
||||
});
|
||||
|
||||
function withNavigator(value: unknown): void {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("is true when navigator.brave is present", () => {
|
||||
withNavigator({ brave: { isBrave: async () => true } });
|
||||
expect(isBraveBrowser()).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a Chromium without navigator.brave", () => {
|
||||
withNavigator({ userAgent: "Mozilla/5.0 Chrome/130" });
|
||||
expect(isBraveBrowser()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Why `pushManager.subscribe()` refused, in the terms the Browser Push card
|
||||
* explains to a person.
|
||||
*
|
||||
* Only the browser's own refusal is classified here. A missing VAPID key and
|
||||
* a failed save are server answers and keep their own messages in the card.
|
||||
*
|
||||
* - `denied`: notifications are blocked for this site
|
||||
* (`Notification.permission === "denied"`), whatever the error said.
|
||||
* - `push-service` / `push-service-brave`: the browser could not register
|
||||
* with its push service ("AbortError: Registration failed - push service
|
||||
* error"). The page did nothing wrong and cannot fix it; the browser has to
|
||||
* reach its push service. Brave ships with that switched off, which is the
|
||||
* common case, so Brave gets the setting named.
|
||||
* - `unknown`: anything else, which keeps the generic message. That includes
|
||||
* a dismissed permission prompt (`NotAllowedError`, or an `AbortError`
|
||||
* about permission in older Chromium builds): the permission is still
|
||||
* "default", the person can try again, and calling it a push-service fault
|
||||
* would send them to the wrong setting.
|
||||
*/
|
||||
export type WebPushSubscribeFailure =
|
||||
"denied" | "push-service" | "push-service-brave" | "unknown";
|
||||
|
||||
export interface WebPushSubscribeContext {
|
||||
/** `Notification.permission` after the attempt. */
|
||||
permission: NotificationPermission;
|
||||
/** The browser announces itself as Brave (`navigator.brave`). */
|
||||
isBrave: boolean;
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return typeof error === "object" && error !== null && "name" in error
|
||||
? String((error as { name: unknown }).name)
|
||||
: "";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return typeof error === "object" && error !== null && "message" in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: "";
|
||||
}
|
||||
|
||||
export function classifyWebPushSubscribeFailure(
|
||||
error: unknown,
|
||||
{ permission, isBrave }: WebPushSubscribeContext,
|
||||
): WebPushSubscribeFailure {
|
||||
const name = errorName(error);
|
||||
const message = errorMessage(error);
|
||||
|
||||
if (permission === "denied") return "denied";
|
||||
if (name !== "AbortError") return "unknown";
|
||||
if (/permission/i.test(message)) return "unknown";
|
||||
// A key the server handed out that the browser would not take is the
|
||||
// server's problem, not the push service's.
|
||||
if (/applicationServerKey/i.test(message)) return "unknown";
|
||||
return isBrave ? "push-service-brave" : "push-service";
|
||||
}
|
||||
|
||||
/**
|
||||
* Brave exposes `navigator.brave` so a page can tell it apart from Chrome,
|
||||
* whose user agent it otherwise copies.
|
||||
*/
|
||||
export function isBraveBrowser(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const brave = (navigator as Navigator & { brave?: unknown }).brave;
|
||||
return typeof brave === "object" && brave !== null;
|
||||
}
|
||||
@@ -2011,8 +2011,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Mint a measurement-ingest token",
|
||||
description:
|
||||
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, and `POST /api/custom-metrics/{id}/entries` for a custom metric its owner already created (it cannot list, create, edit or delete metrics, so the id comes from the metric's page in the app). Measurement rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the custom-metric list and value reads, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -294,7 +294,8 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Custom metrics"],
|
||||
summary: "Log a value",
|
||||
description:
|
||||
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.",
|
||||
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.\n\n" +
|
||||
"**Narrow token.** Also reachable with a `measurements:write` Bearer (minted at `POST /api/tokens/measurements`), the supported way to push a custom metric's value from a script or a home-automation rule. Such a credential writes only to its owner's own live metrics: an id that is unknown, deleted, or another account's answers the same 404, and a request carrying the account selector is refused 403 before any sharing grant is read. This POST is all the scope reaches on custom metrics. The value list on this same path, the metric list, and creating, editing or deleting a metric or a value name no scope and refuse it 403, so the metric id has to come from its owner: it is the path segment after `/custom-metrics/` in the address of the metric's page in the app. The entry carries no source of its own; the audit row names the token that wrote it. Every other narrow token, `workouts:write` included, is refused 403.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
|
||||
@@ -457,7 +457,7 @@ describe("B7 — cookie sessions are untouched", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("B8 — the measurement-ingest scope reaches its two routes and no others", () => {
|
||||
describe("B8 — the measurement-ingest scope reaches its measurement routes and no others", () => {
|
||||
/**
|
||||
* A second account, plus a live, accepted, record-wide grant letting the
|
||||
* token's owner write to it.
|
||||
@@ -948,6 +948,281 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe
|
||||
});
|
||||
});
|
||||
|
||||
describe("B10 — the measurement-ingest scope logs values on its owner's custom metrics (#1377)", () => {
|
||||
const OTHER_ID = "user-bearer-scope-other";
|
||||
|
||||
async function seedMetric(userId = USER_ID, name = "Grip strength") {
|
||||
return getPrismaClient().customMetric.create({
|
||||
data: { userId, name, unit: "kg" },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedOtherAccount(): Promise<void> {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: OTHER_ID,
|
||||
username: "scope-other",
|
||||
email: "scope-other@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
function postValue(
|
||||
metricId: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest(
|
||||
`https://health.example/api/custom-metrics/${metricId}/entries`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
value: 41.5,
|
||||
measuredAt: "2026-11-01T07:00:00.000Z",
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("admits the scope on its owner's metric, and the audit row names the token", async () => {
|
||||
const metric = await seedMetric();
|
||||
const raw = await armToken(["measurements:write"], "cmwrite1");
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
|
||||
const res = await POST(postValue(metric.id), params(metric.id));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const row = await getPrismaClient().customMetricEntry.findFirstOrThrow({
|
||||
where: { customMetricId: metric.id },
|
||||
});
|
||||
expect(row.userId).toBe(USER_ID);
|
||||
expect(row.value).toBe(41.5);
|
||||
// The metric's unit, snapshotted; the token cannot name one.
|
||||
expect(row.unit).toBe("kg");
|
||||
|
||||
// The entry has no source column, so the audit row is what says a bridge
|
||||
// wrote it — and which token, so the right one can be revoked.
|
||||
const token = await getPrismaClient().apiToken.findUniqueOrThrow({
|
||||
where: { tokenHash: hashToken(raw) },
|
||||
});
|
||||
const audit = await getPrismaClient().auditLog.findFirstOrThrow({
|
||||
where: { action: "customMetricEntry.create", userId: USER_ID },
|
||||
});
|
||||
expect(JSON.parse(audit.details!)).toMatchObject({
|
||||
customMetricId: metric.id,
|
||||
entryId: row.id,
|
||||
ingestTokenId: token.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("a retry under the same Idempotency-Key writes one value", async () => {
|
||||
const metric = await seedMetric();
|
||||
await armToken(["measurements:write"], "cmwrite2");
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
const key = { "idempotency-key": "grip-2026-11-01" };
|
||||
|
||||
const first = await POST(postValue(metric.id, key), params(metric.id));
|
||||
const second = await POST(postValue(metric.id, key), params(metric.id));
|
||||
|
||||
expect(first.status).toBe(201);
|
||||
expect(second.status).toBe(201);
|
||||
const a = (await first.json()) as { data: { id: string } };
|
||||
const b = (await second.json()) as { data: { id: string } };
|
||||
expect(b.data.id).toBe(a.data.id);
|
||||
expect(await getPrismaClient().customMetricEntry.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses another account's metric id with the same 404 as an unknown one", async () => {
|
||||
await seedOtherAccount();
|
||||
const foreign = await seedMetric(OTHER_ID, "Their metric");
|
||||
await armToken(["measurements:write"], "cmwrite3");
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
|
||||
const res = await POST(postValue(foreign.id), params(foreign.id));
|
||||
const unknown = await POST(
|
||||
postValue("cm_does_not_exist"),
|
||||
params("cm_does_not_exist"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(unknown.status).toBe(404);
|
||||
// Indistinguishable, so the 404 tells a token holder nothing about which
|
||||
// ids exist on other accounts.
|
||||
expect((await res.json()).error).toBe((await unknown.json()).error);
|
||||
// Nothing on the other account, and nothing quietly on the holder's own.
|
||||
expect(await getPrismaClient().customMetricEntry.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a deleted metric of its own owner", async () => {
|
||||
const metric = await seedMetric();
|
||||
await getPrismaClient().customMetric.update({
|
||||
where: { id: metric.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await armToken(["measurements:write"], "cmwrite4");
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
|
||||
const res = await POST(postValue(metric.id), params(metric.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await getPrismaClient().customMetricEntry.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["workouts:write"],
|
||||
["documents:write"],
|
||||
["medication:ingest"],
|
||||
["fhir:read"],
|
||||
["health:read", "health:write"],
|
||||
])(
|
||||
"still refuses a narrow token that is not the ingest scope: %s",
|
||||
async (...scopes) => {
|
||||
const metric = await seedMetric();
|
||||
await armToken(scopes, `cmnarrow-${scopes.join("-")}`);
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
|
||||
const res = await POST(postValue(metric.id), params(metric.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await getPrismaClient().customMetricEntry.count()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("is refused on every other custom-metric leg", async () => {
|
||||
const metric = await seedMetric();
|
||||
const entry = await getPrismaClient().customMetricEntry.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
customMetricId: metric.id,
|
||||
value: 40,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-31T07:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await armToken(["measurements:write"], "cmwrite5");
|
||||
const bearer = headerJar.get("authorization")!;
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
new NextRequest(`https://health.example${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: bearer,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
} as never);
|
||||
const base = `/api/custom-metrics/${metric.id}`;
|
||||
const entryParams = {
|
||||
params: Promise.resolve({ id: metric.id, entryId: entry.id }),
|
||||
};
|
||||
|
||||
const list = await import("@/app/api/custom-metrics/route");
|
||||
const one = await import("@/app/api/custom-metrics/[id]/route");
|
||||
const values = await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
const single =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/[entryId]/route");
|
||||
const restore =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/restore/route");
|
||||
|
||||
const statuses = {
|
||||
// The list handler takes no request; the Bearer rides `next/headers`.
|
||||
listMetrics: (await list.GET()).status,
|
||||
createMetric: (
|
||||
await list.POST(
|
||||
send("/api/custom-metrics", "POST", { name: "New", unit: "x" }),
|
||||
)
|
||||
).status,
|
||||
readMetric: (await one.GET(send(base, "GET"), params(metric.id))).status,
|
||||
editMetric: (
|
||||
await one.PATCH(send(base, "PATCH", { unit: "lb" }), params(metric.id))
|
||||
).status,
|
||||
deleteMetric: (await one.DELETE(send(base, "DELETE"), params(metric.id)))
|
||||
.status,
|
||||
listValues: (
|
||||
await values.GET(send(`${base}/entries`, "GET"), params(metric.id))
|
||||
).status,
|
||||
editValue: (
|
||||
await single.PATCH(
|
||||
send(`${base}/entries/${entry.id}`, "PATCH", { value: 1 }),
|
||||
entryParams,
|
||||
)
|
||||
).status,
|
||||
deleteValue: (
|
||||
await single.DELETE(
|
||||
send(`${base}/entries/${entry.id}`, "DELETE"),
|
||||
entryParams,
|
||||
)
|
||||
).status,
|
||||
restoreValues: (
|
||||
await restore.POST(
|
||||
send(`${base}/entries/restore`, "POST", { ids: [entry.id] }),
|
||||
params(metric.id),
|
||||
)
|
||||
).status,
|
||||
};
|
||||
|
||||
expect(statuses).toEqual({
|
||||
listMetrics: 403,
|
||||
createMetric: 403,
|
||||
readMetric: 403,
|
||||
editMetric: 403,
|
||||
deleteMetric: 403,
|
||||
listValues: 403,
|
||||
editValue: 403,
|
||||
deleteValue: 403,
|
||||
restoreValues: 403,
|
||||
});
|
||||
// And nothing moved.
|
||||
const after = await getPrismaClient().customMetric.findUniqueOrThrow({
|
||||
where: { id: metric.id },
|
||||
});
|
||||
expect(after.unit).toBe("kg");
|
||||
expect(after.deletedAt).toBeNull();
|
||||
expect(await getPrismaClient().customMetric.count()).toBe(1);
|
||||
const entryAfter =
|
||||
await getPrismaClient().customMetricEntry.findUniqueOrThrow({
|
||||
where: { id: entry.id },
|
||||
});
|
||||
expect(entryAfter.value).toBe(40);
|
||||
expect(entryAfter.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is refused a shared record's metric its holder may genuinely write to", async () => {
|
||||
await seedOtherAccount();
|
||||
await getPrismaClient().accountGrant.create({
|
||||
data: {
|
||||
grantorId: OTHER_ID,
|
||||
granteeId: USER_ID,
|
||||
access: "WRITE",
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const shared = await seedMetric(OTHER_ID, "Shared metric");
|
||||
await armToken(["measurements:write"], "cmwrite6");
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, OTHER_ID);
|
||||
const { POST } =
|
||||
await import("@/app/api/custom-metrics/[id]/entries/route");
|
||||
|
||||
const res = await POST(postValue(shared.id), params(shared.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await getPrismaClient().customMetricEntry.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("B9 — the workout-ingest scope reaches the workout batch and nothing else", () => {
|
||||
function postWorkouts(entries: Array<Record<string, unknown>>): NextRequest {
|
||||
return new NextRequest("https://health.example/api/workouts/batch", {
|
||||
|
||||
Reference in new issue
Block a user