mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.
The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.
/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.
The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
This commit is contained in:
1 parent
ac31ac40b5
commit
d9a31524a2
70 files changed
+6063
-654
No files matched your search
+1000
-556
File diff suppressed because it is too large.
Load diff
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
|
||||
"endedPeriodOnDate": "Periode an diesem Tag beendet",
|
||||
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
|
||||
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
|
||||
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da.",
|
||||
"feed": {
|
||||
"title": "Kalender-Abo",
|
||||
"description": "Deine nächsten Perioden als ganztägige Termine in der Kalender-App, die du schon nutzt.",
|
||||
"disclosure": "Dein Kalenderanbieter (Google, iCloud, Outlook) speichert diese Daten auf seinen Servern. Schaltest du das Abo später ab, bleiben Kopien, die er schon hat, dort liegen.",
|
||||
"turnOn": "Abo einschalten",
|
||||
"error": "Das Kalender-Abo konnte nicht geändert werden. Versuch es noch einmal.",
|
||||
"linkHidden": "Ein Link, der mit {prefix} beginnt, ist aktiv. Er wird nur einmal gezeigt, beim Erstellen; erneuere ihn, wenn du einen neuen brauchst.",
|
||||
"linkLabel": "Abo-Link",
|
||||
"linkOnce": "Nur jetzt sichtbar. Füge ihn in deine Kalender-App ein oder erneuere ihn später für einen neuen.",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Link kopiert.",
|
||||
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.",
|
||||
"openInCalendar": "In Kalender-App öffnen",
|
||||
"notFetched": "Noch nicht abgerufen.",
|
||||
"lastFetched": "Zuletzt {when} von {fetcher} abgerufen.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Kalender",
|
||||
"APPLE": "Apple Kalender",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "einer Kalender-App"
|
||||
},
|
||||
"horizon": "Zyklen voraus",
|
||||
"history": "Vergangene Zyklen",
|
||||
"historyNone": "Keine",
|
||||
"titles": "Termintitel",
|
||||
"titleMode": {
|
||||
"DISCREET": "Diskret (●)",
|
||||
"DESCRIPTIVE": "Beschreibend"
|
||||
},
|
||||
"fertile": "Fruchtbares Fenster",
|
||||
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
|
||||
"rotate": "Link erneuern",
|
||||
"rotateTitle": "Link erneuern?",
|
||||
"rotateBody": "Kalender mit dem alten Link werden nicht mehr aktualisiert. Du bekommst einen neuen Link, den du stattdessen einträgst.",
|
||||
"turnOff": "Ausschalten",
|
||||
"turnOffTitle": "Kalender-Abo ausschalten?",
|
||||
"turnOffBody": "Der Link funktioniert nicht mehr. Kopien bei deinem Kalenderanbieter bleiben, bis du den Kalender dort entfernst.",
|
||||
"help": {
|
||||
"title": "So trägst du es ein",
|
||||
"google": "Google Kalender: Weitere Kalender, Per URL, dann den Link einfügen. Google aktualisiert etwa alle 12 bis 24 Stunden.",
|
||||
"apple": "Apple Kalender: am Mac Ablage, Neues Kalenderabonnement; am iPhone Einstellungen, Kalender, Accounts, Account hinzufügen, Andere, Kalenderabo hinzufügen.",
|
||||
"outlook": "Outlook: Kalender hinzufügen, Aus dem Internet abonnieren, dann den Link einfügen. Outlook aktualisiert alle paar Stunden."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Zyklus (HealthLog)",
|
||||
"periodEstimate": "Periode (Schätzung)",
|
||||
"periodLogged": "Periode (erfasst)",
|
||||
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
|
||||
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
|
||||
"bandOne": "±{count} Tag",
|
||||
"bandFew": "±{count} Tage",
|
||||
"bandOther": "±{count} Tage"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Spätere Zyklen",
|
||||
"around": "Um den {date}",
|
||||
"state": {
|
||||
"LEARNING": "Bis 3 Zyklen erfasst sind, ist nur die nächste Periode enthalten.",
|
||||
"LATE": "Keine Prognose, solange diese Periode später kommt als erwartet; sie ist zurück, sobald du sie erfasst.",
|
||||
"OFF": "Prognosen sind ausgeschaltet; enthalten sind nur vergangene Zyklen, falls gewählt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
|
||||
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht",
|
||||
"cycleFeedCreate": "{name} hat das Kalender-Abo eingeschaltet",
|
||||
"cycleFeedUpdate": "{name} hat das Kalender-Abo geändert",
|
||||
"cycleFeedRotate": "{name} hat den Link des Kalender-Abos erneuert",
|
||||
"cycleFeedRevoke": "{name} hat das Kalender-Abo ausgeschaltet"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "My period started this day",
|
||||
"endedPeriodOnDate": "My period ended this day",
|
||||
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
|
||||
"startRestored": "Saved. The start on {dates} is back."
|
||||
"startRestored": "Saved. The start on {dates} is back.",
|
||||
"feed": {
|
||||
"title": "Calendar subscription",
|
||||
"description": "Your next periods as all-day events in the calendar app you already use.",
|
||||
"disclosure": "Your calendar provider (Google, iCloud, Outlook) stores these dates on its servers. Turning this off later does not remove copies it already holds.",
|
||||
"turnOn": "Turn on subscription",
|
||||
"error": "The calendar subscription could not be changed. Try again.",
|
||||
"linkHidden": "A link starting with {prefix} is active. It is shown only once, when it is made; rotate it to get a new one.",
|
||||
"linkLabel": "Subscription link",
|
||||
"linkOnce": "Shown only now. Paste it into your calendar app, or rotate it later for a new one.",
|
||||
"copy": "Copy link",
|
||||
"copied": "Link copied.",
|
||||
"copyFailed": "Copying did not work. Select the link and copy it by hand.",
|
||||
"openInCalendar": "Open in calendar app",
|
||||
"notFetched": "Not fetched yet.",
|
||||
"lastFetched": "Last fetched {when} by {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Apple Calendar",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "a calendar app"
|
||||
},
|
||||
"horizon": "Cycles ahead",
|
||||
"history": "Past cycles",
|
||||
"historyNone": "None",
|
||||
"titles": "Event titles",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreet (●)",
|
||||
"DESCRIPTIVE": "Descriptive"
|
||||
},
|
||||
"fertile": "Fertile window",
|
||||
"fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.",
|
||||
"rotate": "Rotate link",
|
||||
"rotateTitle": "Rotate the link?",
|
||||
"rotateBody": "Calendars using the old link stop updating. You get a new link to add instead.",
|
||||
"turnOff": "Turn off",
|
||||
"turnOffTitle": "Turn off the calendar subscription?",
|
||||
"turnOffBody": "The link stops working. Copies your calendar provider already holds stay until you remove the calendar there.",
|
||||
"help": {
|
||||
"title": "Adding it to your calendar",
|
||||
"google": "Google Calendar: Other calendars, From URL, then paste the link. Google refreshes about every 12 to 24 hours.",
|
||||
"apple": "Apple Calendar: on a Mac, File, New Calendar Subscription; on iPhone, Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.",
|
||||
"outlook": "Outlook: Add calendar, Subscribe from web, then paste the link. Outlook refreshes every few hours."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cycle (HealthLog)",
|
||||
"periodEstimate": "Period (estimate)",
|
||||
"periodLogged": "Period (logged)",
|
||||
"fertileEstimate": "Fertile window (estimate)",
|
||||
"likelyStart": "Most likely start {date}, {band}",
|
||||
"bandOne": "±{count} day",
|
||||
"bandFew": "±{count} days",
|
||||
"bandOther": "±{count} days"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Later cycles",
|
||||
"around": "Around {date}",
|
||||
"state": {
|
||||
"LEARNING": "Until 3 cycles are logged, only the next period is included.",
|
||||
"LATE": "No forecast while this period is later than expected; it returns once you log it.",
|
||||
"OFF": "Forecasts are turned off; only past cycles are included, if chosen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Period likely soon",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} merged biomarkers",
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
|
||||
"auditCCreate": "{name} answered the AUDIT-C questions",
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry"
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry",
|
||||
"cycleFeedCreate": "{name} turned on the calendar subscription",
|
||||
"cycleFeedUpdate": "{name} changed the calendar subscription",
|
||||
"cycleFeedRotate": "{name} replaced the calendar subscription link",
|
||||
"cycleFeedRevoke": "{name} turned off the calendar subscription"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mi periodo empezó este día",
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día",
|
||||
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
|
||||
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
|
||||
"startRestored": "Guardado. El inicio del {dates} vuelve a estar.",
|
||||
"feed": {
|
||||
"title": "Suscripción de calendario",
|
||||
"description": "Tus próximos periodos como eventos de todo el día en la app de calendario que ya usas.",
|
||||
"disclosure": "Tu proveedor de calendario (Google, iCloud, Outlook) guarda estas fechas en sus servidores. Desactivarlo más tarde no borra las copias que ya tiene.",
|
||||
"turnOn": "Activar suscripción",
|
||||
"error": "No se pudo cambiar la suscripción de calendario. Inténtalo de nuevo.",
|
||||
"linkHidden": "Hay activo un enlace que empieza por {prefix}. Solo se muestra una vez, al crearlo; renuévalo para obtener uno nuevo.",
|
||||
"linkLabel": "Enlace de suscripción",
|
||||
"linkOnce": "Solo se muestra ahora. Pégalo en tu app de calendario o renuévalo más tarde para obtener uno nuevo.",
|
||||
"copy": "Copiar enlace",
|
||||
"copied": "Enlace copiado.",
|
||||
"copyFailed": "No se pudo copiar. Selecciona el enlace y cópialo a mano.",
|
||||
"openInCalendar": "Abrir en la app de calendario",
|
||||
"notFetched": "Aún no se ha consultado.",
|
||||
"lastFetched": "Última consulta {when} por {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Calendario de Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "una app de calendario"
|
||||
},
|
||||
"horizon": "Ciclos por delante",
|
||||
"history": "Ciclos pasados",
|
||||
"historyNone": "Ninguno",
|
||||
"titles": "Títulos de los eventos",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descriptivo"
|
||||
},
|
||||
"fertile": "Ventana fértil",
|
||||
"fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.",
|
||||
"rotate": "Renovar enlace",
|
||||
"rotateTitle": "¿Renovar el enlace?",
|
||||
"rotateBody": "Los calendarios que usan el enlace antiguo dejan de actualizarse. Recibes un enlace nuevo para añadirlo en su lugar.",
|
||||
"turnOff": "Desactivar",
|
||||
"turnOffTitle": "¿Desactivar la suscripción de calendario?",
|
||||
"turnOffBody": "El enlace deja de funcionar. Las copias que ya tiene tu proveedor de calendario se quedan hasta que elimines allí el calendario.",
|
||||
"help": {
|
||||
"title": "Añadirlo a tu calendario",
|
||||
"google": "Google Calendar: Otros calendarios, Desde URL y pega el enlace. Google actualiza más o menos cada 12 a 24 horas.",
|
||||
"apple": "Calendario de Apple: en Mac, Archivo, Nueva suscripción a calendario; en iPhone, Ajustes, Calendario, Cuentas, Añadir cuenta, Otra, Añadir calendario suscrito.",
|
||||
"outlook": "Outlook: Agregar calendario, Suscribirse desde la web y pega el enlace. Outlook actualiza cada pocas horas."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Periodo (estimación)",
|
||||
"periodLogged": "Periodo (registrado)",
|
||||
"fertileEstimate": "Ventana fértil (estimación)",
|
||||
"likelyStart": "Inicio más probable {date}, {band}",
|
||||
"bandOne": "±{count} día",
|
||||
"bandFew": "±{count} días",
|
||||
"bandOther": "±{count} días"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Ciclos siguientes",
|
||||
"around": "Hacia el {date}",
|
||||
"state": {
|
||||
"LEARNING": "Hasta que haya 3 ciclos registrados, solo se incluye el próximo periodo.",
|
||||
"LATE": "Sin previsión mientras este periodo llegue más tarde de lo esperado; vuelve en cuanto lo registres.",
|
||||
"OFF": "Las previsiones están desactivadas; solo se incluyen ciclos pasados, si los elegiste."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "El periodo llegará pronto",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} combinó biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
|
||||
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C",
|
||||
"cycleFeedCreate": "{name} activó la suscripción de calendario",
|
||||
"cycleFeedUpdate": "{name} cambió la suscripción de calendario",
|
||||
"cycleFeedRotate": "{name} renovó el enlace de la suscripción de calendario",
|
||||
"cycleFeedRevoke": "{name} desactivó la suscripción de calendario"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
|
||||
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
|
||||
"startRestored": "Enregistré. Le début du {dates} est de retour."
|
||||
"startRestored": "Enregistré. Le début du {dates} est de retour.",
|
||||
"feed": {
|
||||
"title": "Abonnement au calendrier",
|
||||
"description": "Vos prochaines règles en événements sur la journée, dans l'application de calendrier que vous utilisez déjà.",
|
||||
"disclosure": "Votre fournisseur de calendrier (Google, iCloud, Outlook) conserve ces dates sur ses serveurs. Désactiver l'abonnement plus tard ne supprime pas les copies qu'il détient déjà.",
|
||||
"turnOn": "Activer l'abonnement",
|
||||
"error": "L'abonnement au calendrier n'a pas pu être modifié. Réessayez.",
|
||||
"linkHidden": "Un lien commençant par {prefix} est actif. Il n'est affiché qu'une fois, à sa création ; renouvelez-le pour en obtenir un nouveau.",
|
||||
"linkLabel": "Lien d'abonnement",
|
||||
"linkOnce": "Affiché uniquement maintenant. Collez-le dans votre application de calendrier, ou renouvelez-le plus tard pour en obtenir un nouveau.",
|
||||
"copy": "Copier le lien",
|
||||
"copied": "Lien copié.",
|
||||
"copyFailed": "La copie n'a pas fonctionné. Sélectionnez le lien et copiez-le manuellement.",
|
||||
"openInCalendar": "Ouvrir dans l'application de calendrier",
|
||||
"notFetched": "Pas encore récupéré.",
|
||||
"lastFetched": "Dernière récupération {when} par {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Calendrier Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "une application de calendrier"
|
||||
},
|
||||
"horizon": "Cycles à venir",
|
||||
"history": "Cycles passés",
|
||||
"historyNone": "Aucun",
|
||||
"titles": "Titres des événements",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discret (●)",
|
||||
"DESCRIPTIVE": "Explicite"
|
||||
},
|
||||
"fertile": "Fenêtre fertile",
|
||||
"fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.",
|
||||
"rotate": "Renouveler le lien",
|
||||
"rotateTitle": "Renouveler le lien ?",
|
||||
"rotateBody": "Les calendriers qui utilisent l'ancien lien ne sont plus mis à jour. Vous recevez un nouveau lien à ajouter à la place.",
|
||||
"turnOff": "Désactiver",
|
||||
"turnOffTitle": "Désactiver l'abonnement au calendrier ?",
|
||||
"turnOffBody": "Le lien ne fonctionne plus. Les copies que détient déjà votre fournisseur de calendrier restent jusqu'à ce que vous y supprimiez le calendrier.",
|
||||
"help": {
|
||||
"title": "L'ajouter à votre calendrier",
|
||||
"google": "Google Agenda : Autres agendas, À partir de l'URL, puis collez le lien. Google actualise environ toutes les 12 à 24 heures.",
|
||||
"apple": "Calendrier Apple : sur Mac, Fichier, Nouvel abonnement à un calendrier ; sur iPhone, Réglages, Calendrier, Comptes, Ajouter un compte, Autre, Ajouter un calendrier avec abonnement.",
|
||||
"outlook": "Outlook : Ajouter un calendrier, S'abonner à partir du web, puis collez le lien. Outlook actualise toutes les quelques heures."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cycle (HealthLog)",
|
||||
"periodEstimate": "Règles (estimation)",
|
||||
"periodLogged": "Règles (enregistrées)",
|
||||
"fertileEstimate": "Fenêtre fertile (estimation)",
|
||||
"likelyStart": "Début le plus probable {date}, {band}",
|
||||
"bandOne": "±{count} jour",
|
||||
"bandFew": "±{count} jours",
|
||||
"bandOther": "±{count} jours"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Cycles suivants",
|
||||
"around": "Vers le {date}",
|
||||
"state": {
|
||||
"LEARNING": "Tant que 3 cycles ne sont pas enregistrés, seules les prochaines règles sont incluses.",
|
||||
"LATE": "Pas de prévision tant que ces règles sont plus tardives que prévu ; elle revient dès que vous les enregistrez.",
|
||||
"OFF": "Les prévisions sont désactivées ; seuls les cycles passés sont inclus, si vous les avez choisis."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Règles probablement bientôt",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
|
||||
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C",
|
||||
"cycleFeedCreate": "{name} a activé l'abonnement au calendrier",
|
||||
"cycleFeedUpdate": "{name} a modifié l'abonnement au calendrier",
|
||||
"cycleFeedRotate": "{name} a renouvelé le lien de l'abonnement au calendrier",
|
||||
"cycleFeedRevoke": "{name} a désactivé l'abonnement au calendrier"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
|
||||
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
|
||||
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
|
||||
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente.",
|
||||
"feed": {
|
||||
"title": "Abbonamento al calendario",
|
||||
"description": "Le tue prossime mestruazioni come eventi di un'intera giornata nell'app di calendario che usi già.",
|
||||
"disclosure": "Il tuo fornitore di calendario (Google, iCloud, Outlook) conserva queste date sui suoi server. Disattivare l'abbonamento in seguito non elimina le copie che ha già.",
|
||||
"turnOn": "Attiva abbonamento",
|
||||
"error": "Non è stato possibile modificare l'abbonamento al calendario. Riprova.",
|
||||
"linkHidden": "È attivo un link che inizia con {prefix}. Viene mostrato una sola volta, quando viene creato; rinnovalo per averne uno nuovo.",
|
||||
"linkLabel": "Link di abbonamento",
|
||||
"linkOnce": "Visibile solo adesso. Incollalo nella tua app di calendario, oppure rinnovalo più tardi per averne uno nuovo.",
|
||||
"copy": "Copia link",
|
||||
"copied": "Link copiato.",
|
||||
"copyFailed": "La copia non è riuscita. Seleziona il link e copialo a mano.",
|
||||
"openInCalendar": "Apri nell'app di calendario",
|
||||
"notFetched": "Non ancora scaricato.",
|
||||
"lastFetched": "Ultimo scaricamento {when} da {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Calendar",
|
||||
"APPLE": "Calendario di Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "un'app di calendario"
|
||||
},
|
||||
"horizon": "Cicli in avanti",
|
||||
"history": "Cicli passati",
|
||||
"historyNone": "Nessuno",
|
||||
"titles": "Titoli degli eventi",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descrittivo"
|
||||
},
|
||||
"fertile": "Finestra fertile",
|
||||
"fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.",
|
||||
"rotate": "Rinnova link",
|
||||
"rotateTitle": "Rinnovare il link?",
|
||||
"rotateBody": "I calendari che usano il vecchio link smettono di aggiornarsi. Ricevi un nuovo link da aggiungere al suo posto.",
|
||||
"turnOff": "Disattiva",
|
||||
"turnOffTitle": "Disattivare l'abbonamento al calendario?",
|
||||
"turnOffBody": "Il link smette di funzionare. Le copie che il tuo fornitore di calendario ha già restano finché non rimuovi lì il calendario.",
|
||||
"help": {
|
||||
"title": "Aggiungerlo al calendario",
|
||||
"google": "Google Calendar: Altri calendari, Da URL, poi incolla il link. Google aggiorna circa ogni 12-24 ore.",
|
||||
"apple": "Calendario di Apple: sul Mac, File, Nuovo abbonamento a calendario; su iPhone, Impostazioni, Calendario, Account, Aggiungi account, Altro, Aggiungi calendario sottoscritto.",
|
||||
"outlook": "Outlook: Aggiungi calendario, Sottoscrivi dal Web, poi incolla il link. Outlook aggiorna ogni poche ore."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Mestruazioni (stima)",
|
||||
"periodLogged": "Mestruazioni (registrate)",
|
||||
"fertileEstimate": "Finestra fertile (stima)",
|
||||
"likelyStart": "Inizio più probabile {date}, {band}",
|
||||
"bandOne": "±{count} giorno",
|
||||
"bandFew": "±{count} giorni",
|
||||
"bandOther": "±{count} giorni"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Cicli successivi",
|
||||
"around": "Intorno al {date}",
|
||||
"state": {
|
||||
"LEARNING": "Finché non sono registrati 3 cicli, è incluso solo il prossimo ciclo mestruale.",
|
||||
"LATE": "Nessuna previsione finché queste mestruazioni sono più in ritardo del previsto; torna appena le registri.",
|
||||
"OFF": "Le previsioni sono disattivate; sono inclusi solo i cicli passati, se li hai scelti."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Ciclo probabilmente in arrivo",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} ha unito dei biomarcatori",
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
|
||||
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C",
|
||||
"cycleFeedCreate": "{name} ha attivato l'abbonamento al calendario",
|
||||
"cycleFeedUpdate": "{name} ha modificato l'abbonamento al calendario",
|
||||
"cycleFeedRotate": "{name} ha rinnovato il link dell'abbonamento al calendario",
|
||||
"cycleFeedRevoke": "{name} ha disattivato l'abbonamento al calendario"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "이 날 생리를 시작했어요",
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요",
|
||||
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요.",
|
||||
"feed": {
|
||||
"title": "캘린더 구독",
|
||||
"description": "다음 생리 예정일을 지금 쓰는 캘린더 앱에 종일 일정으로 보여줘요.",
|
||||
"disclosure": "캘린더 제공업체(Google, iCloud, Outlook)는 이 날짜를 자사 서버에 저장해요. 나중에 구독을 꺼도 이미 가져간 사본은 지워지지 않아요.",
|
||||
"turnOn": "구독 켜기",
|
||||
"error": "캘린더 구독을 변경하지 못했어요. 다시 시도해 주세요.",
|
||||
"linkHidden": "{prefix}(으)로 시작하는 링크가 활성 상태예요. 링크는 만들 때 한 번만 보여요. 새 링크가 필요하면 다시 발급하세요.",
|
||||
"linkLabel": "구독 링크",
|
||||
"linkOnce": "지금만 보여요. 캘린더 앱에 붙여 넣거나, 나중에 다시 발급해 새 링크를 받으세요.",
|
||||
"copy": "링크 복사",
|
||||
"copied": "링크를 복사했어요.",
|
||||
"copyFailed": "복사하지 못했어요. 링크를 선택해 직접 복사하세요.",
|
||||
"openInCalendar": "캘린더 앱에서 열기",
|
||||
"notFetched": "아직 가져가지 않았어요.",
|
||||
"lastFetched": "{fetcher}에서 마지막으로 가져감: {when}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google 캘린더",
|
||||
"APPLE": "Apple 캘린더",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "캘린더 앱"
|
||||
},
|
||||
"horizon": "앞으로의 주기",
|
||||
"history": "지난 주기",
|
||||
"historyNone": "없음",
|
||||
"titles": "일정 제목",
|
||||
"titleMode": {
|
||||
"DISCREET": "드러나지 않게 (●)",
|
||||
"DESCRIPTIVE": "내용 표시"
|
||||
},
|
||||
"fertile": "가임기",
|
||||
"fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.",
|
||||
"rotate": "링크 다시 발급",
|
||||
"rotateTitle": "링크를 다시 발급할까요?",
|
||||
"rotateBody": "이전 링크를 쓰는 캘린더는 더 이상 업데이트되지 않아요. 대신 추가할 새 링크를 받게 돼요.",
|
||||
"turnOff": "끄기",
|
||||
"turnOffTitle": "캘린더 구독을 끌까요?",
|
||||
"turnOffBody": "링크가 더 이상 작동하지 않아요. 캘린더 제공업체가 이미 가진 사본은 거기서 캘린더를 삭제할 때까지 남아 있어요.",
|
||||
"help": {
|
||||
"title": "캘린더에 추가하는 방법",
|
||||
"google": "Google 캘린더: 다른 캘린더, URL로 추가에서 링크를 붙여 넣으세요. Google은 대략 12~24시간마다 새로 고쳐요.",
|
||||
"apple": "Apple 캘린더: Mac에서는 파일, 새로운 캘린더 구독. iPhone에서는 설정, 캘린더, 계정, 계정 추가, 기타, 구독 캘린더 추가.",
|
||||
"outlook": "Outlook: 캘린더 추가, 웹에서 구독에서 링크를 붙여 넣으세요. Outlook은 몇 시간마다 새로 고쳐요."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "주기 (HealthLog)",
|
||||
"periodEstimate": "생리 (예상)",
|
||||
"periodLogged": "생리 (기록)",
|
||||
"fertileEstimate": "가임기 (예상)",
|
||||
"likelyStart": "가장 가능성 높은 시작일 {date}, {band}",
|
||||
"bandOne": "±{count}일",
|
||||
"bandFew": "±{count}일",
|
||||
"bandOther": "±{count}일"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "이후 주기",
|
||||
"around": "{date}쯤",
|
||||
"state": {
|
||||
"LEARNING": "주기가 3번 기록될 때까지는 다음 생리만 포함돼요.",
|
||||
"LATE": "이번 생리가 예상보다 늦어지는 동안에는 예측이 없어요. 기록하면 다시 나타나요.",
|
||||
"OFF": "예측이 꺼져 있어요. 선택한 경우 지난 주기만 포함돼요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
|
||||
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요",
|
||||
"cycleFeedCreate": "{name} 님이 캘린더 구독을 켰어요",
|
||||
"cycleFeedUpdate": "{name} 님이 캘린더 구독을 변경했어요",
|
||||
"cycleFeedRotate": "{name} 님이 캘린더 구독 링크를 다시 발급했어요",
|
||||
"cycleFeedRevoke": "{name} 님이 캘린더 구독을 껐어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Mijn menstruatie begon op deze dag",
|
||||
"endedPeriodOnDate": "Mijn menstruatie eindigde op deze dag",
|
||||
"startAbsorbed": "Begin van menstruatie opgeslagen. Je begin op {absorbed} is samengevoegd met {date}.",
|
||||
"startRestored": "Opgeslagen. Het begin op {dates} is terug."
|
||||
"startRestored": "Opgeslagen. Het begin op {dates} is terug.",
|
||||
"feed": {
|
||||
"title": "Agenda-abonnement",
|
||||
"description": "Je volgende menstruaties als afspraken voor de hele dag in de agenda-app die je al gebruikt.",
|
||||
"disclosure": "Je agendaprovider (Google, iCloud, Outlook) bewaart deze datums op zijn servers. Als je dit later uitzet, blijven kopieën die hij al heeft daar staan.",
|
||||
"turnOn": "Abonnement aanzetten",
|
||||
"error": "Het agenda-abonnement kon niet worden gewijzigd. Probeer het opnieuw.",
|
||||
"linkHidden": "Er is een link actief die begint met {prefix}. Die wordt maar één keer getoond, bij het aanmaken; vernieuw hem voor een nieuwe.",
|
||||
"linkLabel": "Abonnementslink",
|
||||
"linkOnce": "Alleen nu zichtbaar. Plak hem in je agenda-app, of vernieuw hem later voor een nieuwe.",
|
||||
"copy": "Link kopiëren",
|
||||
"copied": "Link gekopieerd.",
|
||||
"copyFailed": "Kopiëren is niet gelukt. Selecteer de link en kopieer hem zelf.",
|
||||
"openInCalendar": "Openen in agenda-app",
|
||||
"notFetched": "Nog niet opgehaald.",
|
||||
"lastFetched": "Laatst opgehaald {when} door {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Apple Agenda",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "een agenda-app"
|
||||
},
|
||||
"horizon": "Cycli vooruit",
|
||||
"history": "Eerdere cycli",
|
||||
"historyNone": "Geen",
|
||||
"titles": "Titels van afspraken",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreet (●)",
|
||||
"DESCRIPTIVE": "Beschrijvend"
|
||||
},
|
||||
"fertile": "Vruchtbare periode",
|
||||
"fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.",
|
||||
"rotate": "Link vernieuwen",
|
||||
"rotateTitle": "Link vernieuwen?",
|
||||
"rotateBody": "Agenda's met de oude link worden niet meer bijgewerkt. Je krijgt een nieuwe link om in plaats daarvan toe te voegen.",
|
||||
"turnOff": "Uitzetten",
|
||||
"turnOffTitle": "Agenda-abonnement uitzetten?",
|
||||
"turnOffBody": "De link werkt niet meer. Kopieën die je agendaprovider al heeft, blijven staan tot je de agenda daar verwijdert.",
|
||||
"help": {
|
||||
"title": "Toevoegen aan je agenda",
|
||||
"google": "Google Agenda: Andere agenda's, Via URL, en plak de link. Google ververst ongeveer elke 12 tot 24 uur.",
|
||||
"apple": "Apple Agenda: op de Mac Archief, Nieuw agenda-abonnement; op de iPhone Instellingen, Agenda, Accounts, Voeg account toe, Andere, Voeg agenda-abonnement toe.",
|
||||
"outlook": "Outlook: Agenda toevoegen, Abonneren vanaf internet, en plak de link. Outlook ververst om de paar uur."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cyclus (HealthLog)",
|
||||
"periodEstimate": "Menstruatie (schatting)",
|
||||
"periodLogged": "Menstruatie (geregistreerd)",
|
||||
"fertileEstimate": "Vruchtbare periode (schatting)",
|
||||
"likelyStart": "Meest waarschijnlijke begin {date}, {band}",
|
||||
"bandOne": "±{count} dag",
|
||||
"bandFew": "±{count} dagen",
|
||||
"bandOther": "±{count} dagen"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Latere cycli",
|
||||
"around": "Rond {date}",
|
||||
"state": {
|
||||
"LEARNING": "Tot er 3 cycli zijn geregistreerd, is alleen de volgende menstruatie opgenomen.",
|
||||
"LATE": "Geen voorspelling zolang deze menstruatie later is dan verwacht; die komt terug zodra je haar registreert.",
|
||||
"OFF": "Voorspellingen staan uit; alleen eerdere cycli zijn opgenomen, als je die hebt gekozen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Menstruatie waarschijnlijk binnenkort",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} heeft biomarkers samengevoegd",
|
||||
"medicationIngredientsUpdate": "{name} heeft de samenstelling van een supplement gewijzigd",
|
||||
"auditCCreate": "{name} heeft de AUDIT-C-vragen beantwoord",
|
||||
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd"
|
||||
"auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd",
|
||||
"cycleFeedCreate": "{name} heeft het agenda-abonnement aangezet",
|
||||
"cycleFeedUpdate": "{name} heeft het agenda-abonnement gewijzigd",
|
||||
"cycleFeedRotate": "{name} heeft de link van het agenda-abonnement vernieuwd",
|
||||
"cycleFeedRevoke": "{name} heeft het agenda-abonnement uitgezet"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
|
||||
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
|
||||
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
|
||||
"startRestored": "Zapisano. Początek z {dates} jest z powrotem.",
|
||||
"feed": {
|
||||
"title": "Subskrypcja kalendarza",
|
||||
"description": "Twoje kolejne miesiączki jako wydarzenia całodniowe w aplikacji kalendarza, której już używasz.",
|
||||
"disclosure": "Twój dostawca kalendarza (Google, iCloud, Outlook) przechowuje te daty na swoich serwerach. Późniejsze wyłączenie nie usuwa kopii, które już ma.",
|
||||
"turnOn": "Włącz subskrypcję",
|
||||
"error": "Nie udało się zmienić subskrypcji kalendarza. Spróbuj ponownie.",
|
||||
"linkHidden": "Aktywny jest link zaczynający się od {prefix}. Jest pokazywany tylko raz, przy utworzeniu; odnów go, aby dostać nowy.",
|
||||
"linkLabel": "Link subskrypcji",
|
||||
"linkOnce": "Widoczny tylko teraz. Wklej go do aplikacji kalendarza albo odnów go później, aby dostać nowy.",
|
||||
"copy": "Kopiuj link",
|
||||
"copied": "Link skopiowany.",
|
||||
"copyFailed": "Kopiowanie nie zadziałało. Zaznacz link i skopiuj go ręcznie.",
|
||||
"openInCalendar": "Otwórz w aplikacji kalendarza",
|
||||
"notFetched": "Jeszcze nie pobrano.",
|
||||
"lastFetched": "Ostatnio pobrane {when} przez: {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Kalendarz Google",
|
||||
"APPLE": "Kalendarz Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "aplikacja kalendarza"
|
||||
},
|
||||
"horizon": "Cykle do przodu",
|
||||
"history": "Poprzednie cykle",
|
||||
"historyNone": "Brak",
|
||||
"titles": "Tytuły wydarzeń",
|
||||
"titleMode": {
|
||||
"DISCREET": "Dyskretne (●)",
|
||||
"DESCRIPTIVE": "Opisowe"
|
||||
},
|
||||
"fertile": "Okno płodne",
|
||||
"fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.",
|
||||
"rotate": "Odnów link",
|
||||
"rotateTitle": "Odnowić link?",
|
||||
"rotateBody": "Kalendarze ze starym linkiem przestaną się aktualizować. Dostaniesz nowy link, który dodasz zamiast niego.",
|
||||
"turnOff": "Wyłącz",
|
||||
"turnOffTitle": "Wyłączyć subskrypcję kalendarza?",
|
||||
"turnOffBody": "Link przestanie działać. Kopie, które ma już Twój dostawca kalendarza, zostaną, dopóki nie usuniesz tam kalendarza.",
|
||||
"help": {
|
||||
"title": "Jak dodać go do kalendarza",
|
||||
"google": "Kalendarz Google: Inne kalendarze, Z adresu URL, potem wklej link. Google odświeża mniej więcej co 12 do 24 godzin.",
|
||||
"apple": "Kalendarz Apple: na Macu Plik, Nowa subskrypcja kalendarza; na iPhonie Ustawienia, Kalendarz, Konta, Dodaj konto, Inne, Dodaj subskrybowany kalendarz.",
|
||||
"outlook": "Outlook: Dodaj kalendarz, Subskrybuj z sieci Web, potem wklej link. Outlook odświeża co kilka godzin."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Cykl (HealthLog)",
|
||||
"periodEstimate": "Miesiączka (szacunek)",
|
||||
"periodLogged": "Miesiączka (zapisana)",
|
||||
"fertileEstimate": "Okno płodne (szacunek)",
|
||||
"likelyStart": "Najbardziej prawdopodobny początek {date}, {band}",
|
||||
"bandOne": "±{count} dzień",
|
||||
"bandFew": "±{count} dni",
|
||||
"bandOther": "±{count} dni"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Kolejne cykle",
|
||||
"around": "Około {date}",
|
||||
"state": {
|
||||
"LEARNING": "Dopóki nie zapiszesz 3 cykli, uwzględniana jest tylko najbliższa miesiączka.",
|
||||
"LATE": "Brak prognozy, dopóki ta miesiączka spóźnia się bardziej niż oczekiwano; wróci, gdy ją zapiszesz.",
|
||||
"OFF": "Prognozy są wyłączone; uwzględniane są tylko poprzednie cykle, jeśli je wybrano."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} scalił biomarkery",
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
|
||||
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C"
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C",
|
||||
"cycleFeedCreate": "{name} włączył subskrypcję kalendarza",
|
||||
"cycleFeedUpdate": "{name} zmienił subskrypcję kalendarza",
|
||||
"cycleFeedRotate": "{name} odnowił link subskrypcji kalendarza",
|
||||
"cycleFeedRevoke": "{name} wyłączył subskrypcję kalendarza"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "Minha menstruação começou neste dia",
|
||||
"endedPeriodOnDate": "Minha menstruação terminou neste dia",
|
||||
"startAbsorbed": "Início da menstruação salvo. Seu início em {absorbed} foi mesclado em {date}.",
|
||||
"startRestored": "Salvo. O início em {dates} está de volta."
|
||||
"startRestored": "Salvo. O início em {dates} está de volta.",
|
||||
"feed": {
|
||||
"title": "Assinatura de calendário",
|
||||
"description": "Suas próximas menstruações como eventos de dia inteiro no app de calendário que você já usa.",
|
||||
"disclosure": "Seu provedor de calendário (Google, iCloud, Outlook) guarda essas datas nos servidores dele. Desativar depois não apaga as cópias que ele já tem.",
|
||||
"turnOn": "Ativar assinatura",
|
||||
"error": "Não foi possível alterar a assinatura de calendário. Tente de novo.",
|
||||
"linkHidden": "Um link que começa com {prefix} está ativo. Ele só aparece uma vez, ao ser criado; renove-o para ter um novo.",
|
||||
"linkLabel": "Link da assinatura",
|
||||
"linkOnce": "Só aparece agora. Cole no seu app de calendário ou renove depois para ter um novo.",
|
||||
"copy": "Copiar link",
|
||||
"copied": "Link copiado.",
|
||||
"copyFailed": "Não deu para copiar. Selecione o link e copie manualmente.",
|
||||
"openInCalendar": "Abrir no app de calendário",
|
||||
"notFetched": "Ainda não buscado.",
|
||||
"lastFetched": "Última busca {when} por {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Agenda",
|
||||
"APPLE": "Calendário da Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "um app de calendário"
|
||||
},
|
||||
"horizon": "Ciclos à frente",
|
||||
"history": "Ciclos anteriores",
|
||||
"historyNone": "Nenhum",
|
||||
"titles": "Títulos dos eventos",
|
||||
"titleMode": {
|
||||
"DISCREET": "Discreto (●)",
|
||||
"DESCRIPTIVE": "Descritivo"
|
||||
},
|
||||
"fertile": "Janela fértil",
|
||||
"fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.",
|
||||
"rotate": "Renovar link",
|
||||
"rotateTitle": "Renovar o link?",
|
||||
"rotateBody": "Calendários com o link antigo deixam de ser atualizados. Você recebe um novo link para adicionar no lugar.",
|
||||
"turnOff": "Desativar",
|
||||
"turnOffTitle": "Desativar a assinatura de calendário?",
|
||||
"turnOffBody": "O link para de funcionar. As cópias que seu provedor de calendário já tem ficam lá até você remover o calendário.",
|
||||
"help": {
|
||||
"title": "Como adicionar ao seu calendário",
|
||||
"google": "Google Agenda: Outras agendas, Do URL, depois cole o link. O Google atualiza mais ou menos a cada 12 a 24 horas.",
|
||||
"apple": "Calendário da Apple: no Mac, Arquivo, Nova Assinatura de Calendário; no iPhone, Ajustes, Calendário, Contas, Adicionar Conta, Outra, Adicionar Calendário Assinado.",
|
||||
"outlook": "Outlook: Adicionar calendário, Assinar da Web, depois cole o link. O Outlook atualiza a cada poucas horas."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Ciclo (HealthLog)",
|
||||
"periodEstimate": "Menstruação (estimativa)",
|
||||
"periodLogged": "Menstruação (registrada)",
|
||||
"fertileEstimate": "Janela fértil (estimativa)",
|
||||
"likelyStart": "Início mais provável {date}, {band}",
|
||||
"bandOne": "±{count} dia",
|
||||
"bandFew": "±{count} dias",
|
||||
"bandOther": "±{count} dias"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Ciclos seguintes",
|
||||
"around": "Por volta de {date}",
|
||||
"state": {
|
||||
"LEARNING": "Até haver 3 ciclos registrados, só a próxima menstruação é incluída.",
|
||||
"LATE": "Sem previsão enquanto esta menstruação estiver mais atrasada que o esperado; ela volta assim que você a registrar.",
|
||||
"OFF": "As previsões estão desativadas; só ciclos anteriores são incluídos, se escolhidos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Menstruação provavelmente em breve",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} mesclou biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} alterou o que um suplemento contém",
|
||||
"auditCCreate": "{name} respondeu às perguntas AUDIT-C",
|
||||
"auditCDelete": "{name} removeu uma entrada AUDIT-C"
|
||||
"auditCDelete": "{name} removeu uma entrada AUDIT-C",
|
||||
"cycleFeedCreate": "{name} ativou a assinatura de calendário",
|
||||
"cycleFeedUpdate": "{name} alterou a assinatura de calendário",
|
||||
"cycleFeedRotate": "{name} renovou o link da assinatura de calendário",
|
||||
"cycleFeedRevoke": "{name} desativou a assinatura de calendário"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+69
-2
@@ -10026,7 +10026,70 @@
|
||||
"startedPeriodOnDate": "У меня началась менструация в этот день",
|
||||
"endedPeriodOnDate": "У меня закончилась менструация в этот день",
|
||||
"startAbsorbed": "Начало менструации сохранено. Ваше начало {absorbed} объединено с {date}.",
|
||||
"startRestored": "Сохранено. Начало {dates} восстановлено."
|
||||
"startRestored": "Сохранено. Начало {dates} восстановлено.",
|
||||
"feed": {
|
||||
"title": "Подписка на календарь",
|
||||
"description": "Ваши следующие месячные в виде событий на весь день в календаре, которым вы уже пользуетесь.",
|
||||
"disclosure": "Ваш поставщик календаря (Google, iCloud, Outlook) хранит эти даты на своих серверах. Если позже отключить подписку, уже полученные им копии не удалятся.",
|
||||
"turnOn": "Включить подписку",
|
||||
"error": "Не удалось изменить подписку на календарь. Попробуйте ещё раз.",
|
||||
"linkHidden": "Активна ссылка, начинающаяся с {prefix}. Она показывается только один раз, при создании; обновите её, чтобы получить новую.",
|
||||
"linkLabel": "Ссылка подписки",
|
||||
"linkOnce": "Видна только сейчас. Вставьте её в приложение календаря или обновите позже, чтобы получить новую.",
|
||||
"copy": "Копировать ссылку",
|
||||
"copied": "Ссылка скопирована.",
|
||||
"copyFailed": "Скопировать не удалось. Выделите ссылку и скопируйте её вручную.",
|
||||
"openInCalendar": "Открыть в календаре",
|
||||
"notFetched": "Ещё не загружалась.",
|
||||
"lastFetched": "Последняя загрузка {when}, клиент: {fetcher}.",
|
||||
"fetcher": {
|
||||
"GOOGLE": "Google Календарь",
|
||||
"APPLE": "Календарь Apple",
|
||||
"MICROSOFT": "Outlook",
|
||||
"OTHER": "приложение календаря"
|
||||
},
|
||||
"horizon": "Циклов вперёд",
|
||||
"history": "Прошлые циклы",
|
||||
"historyNone": "Нет",
|
||||
"titles": "Названия событий",
|
||||
"titleMode": {
|
||||
"DISCREET": "Незаметно (●)",
|
||||
"DESCRIPTIVE": "Подробно"
|
||||
},
|
||||
"fertile": "Фертильное окно",
|
||||
"fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.",
|
||||
"rotate": "Обновить ссылку",
|
||||
"rotateTitle": "Обновить ссылку?",
|
||||
"rotateBody": "Календари со старой ссылкой перестанут обновляться. Вы получите новую ссылку, которую нужно добавить вместо неё.",
|
||||
"turnOff": "Отключить",
|
||||
"turnOffTitle": "Отключить подписку на календарь?",
|
||||
"turnOffBody": "Ссылка перестанет работать. Копии, которые уже есть у поставщика календаря, останутся, пока вы не удалите там календарь.",
|
||||
"help": {
|
||||
"title": "Как добавить в календарь",
|
||||
"google": "Google Календарь: Другие календари, Добавить по URL, затем вставьте ссылку. Google обновляет примерно раз в 12-24 часа.",
|
||||
"apple": "Календарь Apple: на Mac Файл, Новая подписка на календарь; на iPhone Настройки, Календарь, Учетные записи, Добавить учетную запись, Другое, Добавить подписной календарь.",
|
||||
"outlook": "Outlook: Добавить календарь, Подписаться из Интернета, затем вставьте ссылку. Outlook обновляет каждые несколько часов."
|
||||
},
|
||||
"ics": {
|
||||
"calendarName": "Цикл (HealthLog)",
|
||||
"periodEstimate": "Месячные (оценка)",
|
||||
"periodLogged": "Месячные (записаны)",
|
||||
"fertileEstimate": "Фертильное окно (оценка)",
|
||||
"likelyStart": "Наиболее вероятное начало {date}, {band}",
|
||||
"bandOne": "±{count} день",
|
||||
"bandFew": "±{count} дня",
|
||||
"bandOther": "±{count} дней"
|
||||
}
|
||||
},
|
||||
"projection": {
|
||||
"title": "Следующие циклы",
|
||||
"around": "Около {date}",
|
||||
"state": {
|
||||
"LEARNING": "Пока не записаны 3 цикла, включаются только ближайшие месячные.",
|
||||
"LATE": "Прогноза нет, пока эти месячные запаздывают сильнее ожидаемого; он вернётся, как только вы их запишете.",
|
||||
"OFF": "Прогнозы отключены; включаются только прошлые циклы, если они выбраны."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Скоро вероятная менструация",
|
||||
@@ -13182,7 +13245,11 @@
|
||||
"biomarkerMerge": "{name} объединил(а) показатели",
|
||||
"medicationIngredientsUpdate": "{name} изменил(а) состав добавки",
|
||||
"auditCCreate": "{name} ответил(а) на вопросы AUDIT-C",
|
||||
"auditCDelete": "{name} убрал(а) запись AUDIT-C"
|
||||
"auditCDelete": "{name} убрал(а) запись AUDIT-C",
|
||||
"cycleFeedCreate": "{name} включил(а) подписку на календарь",
|
||||
"cycleFeedUpdate": "{name} изменил(а) подписку на календарь",
|
||||
"cycleFeedRotate": "{name} обновил(а) ссылку подписки на календарь",
|
||||
"cycleFeedRevoke": "{name} отключил(а) подписку на календарь"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"lib/dashboard-layout.ts": 976,
|
||||
"lib/dashboard/snapshot.ts": 1550,
|
||||
"lib/day/records.ts": 1174,
|
||||
"lib/export/backup-plan.ts": 853,
|
||||
"lib/export/backup-plan.ts": 838,
|
||||
"lib/export/coach-backup.ts": 1070,
|
||||
"lib/export/profile-backup.ts": 1019,
|
||||
"lib/fitbit/client.ts": 1262,
|
||||
@@ -86,6 +86,6 @@
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
"proxy.ts": 865
|
||||
"proxy.ts": 846
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 226,
|
||||
@@ -81,7 +80,7 @@
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 408,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 407,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
|
||||
"components/daily/today-hero.tsx :: TodayHero": 488,
|
||||
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
|
||||
@@ -242,7 +241,7 @@
|
||||
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
|
||||
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
|
||||
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
|
||||
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
|
||||
@@ -335,7 +334,7 @@
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
@@ -346,6 +345,6 @@
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 493
|
||||
"proxy.ts :: proxy": 471
|
||||
}
|
||||
}
|
||||
@@ -2892,7 +2892,7 @@
|
||||
"lib/withings/sync.ts": 6,
|
||||
"lib/withings/webhook-handler.ts": 3,
|
||||
"lib/workouts/list-read.ts": 1,
|
||||
"proxy.ts": 29,
|
||||
"proxy.ts": 27,
|
||||
"types/analytics.ts": 8,
|
||||
"types/backups.ts": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* #1350 — the calendar feed carries dates and a title, and nothing else.
|
||||
*
|
||||
* A calendar provider keeps everything it fetches, beyond the reach of any
|
||||
* revoke. So this seeds every kind of day-log content the record can hold —
|
||||
* a note, custom symptom labels, a basal temperature, an ovulation test, the
|
||||
* mucus and cervix signs, intercourse, contraception, a pregnancy test — as
|
||||
* recognisable sentinels on the very rows the feed reads, as if a select had
|
||||
* been widened by mistake, and asserts that no rendered body contains any of
|
||||
* them, in either title mode, with history and the fertile window on.
|
||||
*
|
||||
* Written, then broken once on purpose (a description that interpolated the
|
||||
* day's note) to prove it fails.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { buildCycleFeed } from "@/lib/cycle/feed/build";
|
||||
import { renderIcs } from "@/lib/cycle/ics";
|
||||
|
||||
import {
|
||||
FEED,
|
||||
SUBJECT,
|
||||
profileRow,
|
||||
regularCycles,
|
||||
} from "@/lib/cycle/feed/__tests__/fixtures";
|
||||
|
||||
const db = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const SENTINELS = {
|
||||
note: "SENTINEL-NOTE-zebra",
|
||||
noteEncrypted: "SENTINEL-CIPHER-okapi",
|
||||
symptom: "SENTINEL-SYMPTOM-quokka",
|
||||
symptomKey: "sentinel_symptom_key",
|
||||
bbt: "36.987",
|
||||
wrist: "35.4321",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
mucus: "EGG_WHITE",
|
||||
cervix: "HIGH",
|
||||
sex: "SENTINEL-SEX-tapir",
|
||||
contraceptive: "SENTINEL-CONTRACEPTIVE-ibis",
|
||||
pregnancyTest: "SENTINEL-PREGNANCY-lynx",
|
||||
userId: "subject",
|
||||
};
|
||||
|
||||
const FIRST = "2025-11-18";
|
||||
const NOW = new Date("2026-11-06T12:00:00.000Z");
|
||||
|
||||
function stuffedDayLogs() {
|
||||
const cycles = regularCycles(FIRST);
|
||||
const out = [];
|
||||
for (const c of cycles) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
out.push({
|
||||
id: `log-${c.startDate}-${i}`,
|
||||
userId: SENTINELS.userId,
|
||||
date: addDays(c.startDate, i),
|
||||
flow: i < 4 ? "MEDIUM" : "NONE",
|
||||
basalBodyTempC: Number(SENTINELS.bbt),
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: SENTINELS.opk,
|
||||
cervicalMucus: SENTINELS.mucus,
|
||||
cervixPosition: SENTINELS.cervix,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: true,
|
||||
sexualActivity: SENTINELS.sex,
|
||||
protectedSex: false,
|
||||
pregnancyTest: SENTINELS.pregnancyTest,
|
||||
progesteroneTest: SENTINELS.pregnancyTest,
|
||||
contraceptive: SENTINELS.contraceptive,
|
||||
notes: SENTINELS.note,
|
||||
notesEncrypted: SENTINELS.noteEncrypted,
|
||||
sensitiveEncrypted: SENTINELS.noteEncrypted,
|
||||
symptomLinks: [
|
||||
{
|
||||
symptom: {
|
||||
key: SENTINELS.symptomKey,
|
||||
label: SENTINELS.symptom,
|
||||
},
|
||||
},
|
||||
],
|
||||
_count: { symptomLinks: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
|
||||
db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs());
|
||||
db.measurement.findMany.mockResolvedValue([
|
||||
{
|
||||
measuredAt: new Date("2026-11-01T03:00:00Z"),
|
||||
value: Number(SENTINELS.wrist),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("the calendar feed body", () => {
|
||||
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
|
||||
"carries no day-log content in %s mode",
|
||||
async (titleMode) => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode,
|
||||
historyCycles: 12,
|
||||
horizonCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
// Non-empty, or the assertion below would hold for nothing.
|
||||
expect(built.calendar.events.length).toBeGreaterThan(10);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
for (const [name, value] of Object.entries(SENTINELS)) {
|
||||
expect(body, `${name} leaked into the feed`).not.toContain(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("carries only the properties an all-day date event needs", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode: "DESCRIPTIVE",
|
||||
historyCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
const properties = new Set(
|
||||
body
|
||||
.split("\r\n")
|
||||
.filter((line) => line && !line.startsWith(" "))
|
||||
.map((line) => line.split(/[:;]/)[0]),
|
||||
);
|
||||
expect([...properties].sort()).toEqual(
|
||||
[
|
||||
"BEGIN",
|
||||
"CALSCALE",
|
||||
"CLASS",
|
||||
"DESCRIPTION",
|
||||
"DTEND",
|
||||
"DTSTAMP",
|
||||
"DTSTART",
|
||||
"END",
|
||||
"LAST-MODIFIED",
|
||||
"METHOD",
|
||||
"PRODID",
|
||||
"REFRESH-INTERVAL",
|
||||
"SUMMARY",
|
||||
"TRANSP",
|
||||
"UID",
|
||||
"VERSION",
|
||||
"X-PUBLISHED-TTL",
|
||||
"X-WR-CALNAME",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/cycle/use-cycle-feed.ts",
|
||||
destroys:
|
||||
"the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link",
|
||||
triggers: ["components/cycle/cycle-calendar-feed-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── Medications ─────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean {
|
||||
* reason. Prune an entry only when the value genuinely diverges in a locale.
|
||||
*/
|
||||
const LEGIT_IDENTICAL = new Set<string>([
|
||||
// #1350 — "Google Calendar" is the product's own name in the Spanish and
|
||||
// Italian UI, and "Cycle (HealthLog)" is the French word for the same thing.
|
||||
"cycle.feed.fetcher.GOOGLE",
|
||||
"cycle.feed.ics.calendarName",
|
||||
// Vaccine catalogue: clinical proper nouns whose accepted written form is the
|
||||
// same across these locales. "Hib" carries its Latin binomial verbatim, and
|
||||
// "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue
|
||||
|
||||
@@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Starts the identity-provider handoff, for the browser and for the native client.",
|
||||
"/api/auth/oidc/status":
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/cycle/feed/{token}":
|
||||
"A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
"/api/auth/claim/preview":
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* #1350 — the cycle calendar feed at `/api/cycle/feed/<hlf_token>.ics`.
|
||||
*
|
||||
* A calendar provider polls it from its own servers with no cookie, so the
|
||||
* proxy must pass the request through untouched, keep it out of indexes and
|
||||
* referrers, and leave its caching to the route (`private, no-cache` plus an
|
||||
* ETag) rather than stamping `no-store` over the validator path. The
|
||||
* management routes beside it stay cookie-only and are not on the list.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { PUBLIC_PATHS, proxy } from "../proxy";
|
||||
|
||||
// Built at run time so no token-shaped literal sits in the source.
|
||||
const TOKEN = `hlf_${"7d".repeat(24)}`;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("proxy on /api/cycle/feed/<token>", () => {
|
||||
it("lists the feed prefix, trailing slash included, and not the management namespace", () => {
|
||||
expect(PUBLIC_PATHS).toContain("/api/cycle/feed/");
|
||||
expect(
|
||||
PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a cookie-less GET through to the route", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, {
|
||||
headers: { "user-agent": "Google-Calendar-Importer" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("x-middleware-next")).toBe("1");
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the token out of indexes and referrers", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("leaves the route's Cache-Control alone, so the ETag path survives", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a demo-mode mint at the edge", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest("http://localhost/api/cycle/feed-subscription", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,17 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
|
||||
"app/api/fhir/Patient/$everything/route.ts",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* Routes that resolve through `requireRecordAuth` and are cookie-only: they
|
||||
* refuse a Bearer caller outright, then take the cookie arm. The cycle
|
||||
* calendar feed's mint and rotate (#1350) hand out a standing URL credential,
|
||||
* so no token may reach them and the session needs a fresh proof.
|
||||
*/
|
||||
const RECORD_COOKIE_ONLY_PROOF_ROUTES = [
|
||||
"app/api/cycle/feed-subscription/route.ts",
|
||||
"app/api/cycle/feed-subscription/rotate/route.ts",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* The connection consent. It resolves the session through `getSession`
|
||||
* (OAuth answers in its own error format, not the envelope) and asks for the
|
||||
@@ -143,11 +154,30 @@ describe("the recent-proof gate", () => {
|
||||
[
|
||||
...COOKIE_PROOF_ROUTES,
|
||||
...TOKEN_ARM_COOKIE_PROOF_ROUTES,
|
||||
...RECORD_COOKIE_ONLY_PROOF_ROUTES,
|
||||
...CONSENT_PROOF_ROUTES,
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("a cookie-only record route refuses a token before it asks", () => {
|
||||
for (const rel of RECORD_COOKIE_ONLY_PROOF_ROUTES) {
|
||||
const src = read(rel);
|
||||
expect(importedAs(rel, "requireRecordAuth"), rel).toHaveLength(1);
|
||||
// Every proof call sits after a Bearer refusal in the same handler.
|
||||
const proofs = [...src.matchAll(/await assertRecentCookieProof\(/g)];
|
||||
expect(proofs.length, rel).toBeGreaterThan(0);
|
||||
for (const proof of proofs) {
|
||||
const before = src.slice(0, proof.index);
|
||||
const handlerStart = before.lastIndexOf("apiHandler(");
|
||||
expect(handlerStart, rel).toBeGreaterThan(-1);
|
||||
expect(before.slice(handlerStart), rel).toMatch(
|
||||
/if \(auth\.authMethod !== "cookie"\) return cookieOnlyRefusal\(\);/,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a route that resolves either transport asks only on the cookie", () => {
|
||||
for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
|
||||
expect(read(rel), rel).toMatch(
|
||||
|
||||
@@ -294,6 +294,14 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
// action-bound, short-lived token in the path is the credential. Rate
|
||||
// limited before verification, single-use through the mood entry's unique
|
||||
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
|
||||
// #1350 — the cycle calendar feed. A calendar provider polls it from its
|
||||
// own servers with no session, so the `hlf_` path segment is the
|
||||
// credential. Rate limited before the lookup, HMAC-hashed at rest, every
|
||||
// gate (account, operator switch, cycle module, a carrier's MANAGE grant)
|
||||
// re-read per fetch behind one 404, dates-only content, and scrubbed from
|
||||
// logs by `PATH_SECRET_PATHS`.
|
||||
"app/api/cycle/feed/[token]/route.ts":
|
||||
"calendar provider polls without a session; the hlf_ path token is the credential",
|
||||
"app/api/notifications/action/[token]/route.ts":
|
||||
"signed single-use quick-answer token from a notification action",
|
||||
};
|
||||
@@ -329,6 +337,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
expect(bySegment.length).toBeGreaterThan(0);
|
||||
expect(bySegment).toEqual([
|
||||
"app/api/c/[token]/unlock/route.ts",
|
||||
"app/api/cycle/feed/[token]/route.ts",
|
||||
"app/api/notifications/action/[token]/route.ts",
|
||||
"app/api/whoop/webhook/[token]/route.ts",
|
||||
"app/api/withings/webhook/[token]/route.ts",
|
||||
|
||||
@@ -1403,6 +1403,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "cycle",
|
||||
why: "Setting a period boundary, which re-anchors the neighbouring cycles. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/cycle/feed-subscription/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The record's calendar subscription (#1350): its status, turning it on, its options and turning it off. Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/cycle/feed-subscription/rotate/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "A new link for the record's calendar subscription, the old one revoked in the same transaction (#1350). Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": {
|
||||
domain: "medications",
|
||||
why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
@@ -1879,6 +1887,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: ["C4"],
|
||||
why: "Setting a period boundary, which re-anchors the neighbouring cycles. C4 on the dates it moves.",
|
||||
},
|
||||
"app/api/cycle/feed-subscription/route.ts": {
|
||||
domain: "cycle",
|
||||
conditions: ["C1", "C3", "C4"],
|
||||
why: "The record's calendar subscription (#1350), the one credential a MANAGE delegate may mint. It extends no reach: the feed serves dates the grant already lets the manager read, and every fetch re-reads the manager's live MANAGE grant over the cycle section, so revoking or narrowing the grant ends the link on the provider's next poll, and a handover claim revokes it outright. Cookie-only with a fresh proof on the mint, so a Bearer token cannot leave one behind. C1 on the mint bucket, C3 on the revoke (the credential is unusable after it), C4 on the options.",
|
||||
},
|
||||
"app/api/cycle/feed-subscription/rotate/route.ts": {
|
||||
domain: "cycle",
|
||||
conditions: ["C1"],
|
||||
why: "Replacing the subscription's link in one transaction (#1350). Same argument as the subscription route: no reach beyond the grant, re-checked per fetch, cookie-only with a fresh proof. C1 on the shared mint bucket.",
|
||||
},
|
||||
"app/api/cycle/symptoms/custom/route.ts": {
|
||||
domain: "cycle",
|
||||
conditions: ["C1"],
|
||||
@@ -2295,8 +2313,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* Ending a running course (#1280) joins the record list and the
|
||||
* manage literal: it was refused under a switch while the medication's PUT,
|
||||
* which used to carry the end, admitted a manager. 272 -> 274.
|
||||
*
|
||||
* The cycle calendar subscription (#1350) and its rotate join the record list
|
||||
* and the manage literal: the owner and a MANAGE delegate over the cycle
|
||||
* section may turn it on, change it, rotate it and turn it off. 276 -> 280.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 276;
|
||||
const FROZEN_ENTRY_COUNT = 280;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -3074,7 +3096,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -133,6 +133,8 @@ const GATED_ROUTES: Record<string, string> = {
|
||||
"POST /api/account/grants": "web",
|
||||
"POST /api/admin/backups/[id]/restore": "web",
|
||||
"POST /api/admin/backups/upload": "web",
|
||||
"POST /api/cycle/feed-subscription": "web",
|
||||
"POST /api/cycle/feed-subscription/rotate": "web",
|
||||
"POST /api/admin/encryption/rotate": "web",
|
||||
"POST /api/admin/users/[id]/reset-password": "web",
|
||||
"POST /api/auth/me/mfa/disable": "web",
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(97);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -210,6 +210,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
* ungated. Each carries its reason inline per the bucket-4 contract.
|
||||
*/
|
||||
const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// ── CYCLE CALENDAR FEED (#1350) ───────────────────────────────────
|
||||
// Not ungated: the anonymous feed cannot answer the module's 403 (that
|
||||
// would tell a prober the token is real), so `serveCycleFeed` re-reads
|
||||
// `isCycleAvailableForUser` (the user toggle AND the operator switch) on
|
||||
// every fetch and answers its blunt 404. `requireCycleEnabled` would also
|
||||
// upsert a profile on an anonymous request. The behaviour is pinned in
|
||||
// BEHAVIOURAL_GATE_TESTS below.
|
||||
"src/app/api/cycle/feed/[token]/route.ts",
|
||||
// ── DATA LAYER (mood) ─────────────────────────────────────────────
|
||||
// Raw MoodEntry CRUD. The module gate governs whether mood SURFACES
|
||||
// (nav, dashboard tile, analysis, doctor report), not whether the row
|
||||
@@ -508,6 +516,11 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
*/
|
||||
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
[
|
||||
{
|
||||
path: "src/lib/cycle/feed/__tests__/serve.test.ts",
|
||||
proves:
|
||||
"the anonymous cycle calendar feed answers its blunt 404, and builds nothing, when cycle tracking is unavailable for the record",
|
||||
},
|
||||
{
|
||||
path: "tests/integration/routes/fhir-module-gate.test.ts",
|
||||
proves:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* `POST /api/cycle/feed-subscription/rotate` — a new link for the record's
|
||||
* calendar subscription (#1350).
|
||||
*
|
||||
* One transaction: the live feed is revoked and its successor minted with the
|
||||
* same options, so there is never a moment with two live links or with none.
|
||||
* Calendars holding the old link stop updating on their next poll. The new
|
||||
* URL is returned exactly once.
|
||||
*
|
||||
* Same gates as turning the subscription on: cookie session only, the owner or
|
||||
* a MANAGE delegate over the cycle section, a fresh proof, the operator's
|
||||
* external-access switch, and the per-actor mint bucket.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
FEED_MINT_LIMIT_PER_HOUR,
|
||||
FEED_MINT_WINDOW_MS,
|
||||
cookieOnlyRefusal,
|
||||
feedLinks,
|
||||
noLiveFeed,
|
||||
} from "@/lib/cycle/feed/manage";
|
||||
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
|
||||
import { mintFeedToken } from "@/lib/cycle/feed/token";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireRecordAuth("manage", "cycle");
|
||||
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
|
||||
const { user, actor } = auth;
|
||||
await assertRecentCookieProof(actor, auth.session.id);
|
||||
annotate({ action: { name: "cycle.feed.rotate" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403, {
|
||||
errorCode: "cycle.feed.api_disabled",
|
||||
});
|
||||
}
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle-feed:mint:${actor.id}`,
|
||||
FEED_MINT_LIMIT_PER_HOUR,
|
||||
FEED_MINT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many calendar links, try again later", 429, {
|
||||
errorCode: "cycle.feed.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const minted = mintFeedToken();
|
||||
const rotated = await prisma.$transaction(async (tx) => {
|
||||
const live = await tx.cycleCalendarFeed.findFirst({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
await tx.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const created = await tx.cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
horizonCycles: live.horizonCycles,
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
await auditLog("cycle.feed.rotate", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
feedId: created.id,
|
||||
previousFeedId: live.id,
|
||||
tokenPrefix: created.tokenPrefix,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
if (!rotated) return noLiveFeed();
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...feedLinks(resolveOrigin(request), minted.raw),
|
||||
feed: toFeedSummary(rotated),
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* `/api/cycle/feed-subscription` — the record's calendar subscription
|
||||
* (#1350).
|
||||
*
|
||||
* GET → the status: token prefix, options, fetch statistics, and what
|
||||
* the feed serves right now. Never the hash, never the URL.
|
||||
* POST → turn it on. Mints an `hlf_` token, stores only its HMAC, and
|
||||
* returns the URL exactly once.
|
||||
* PATCH → change the options.
|
||||
* DELETE → turn it off. The row is revoked, never deleted.
|
||||
*
|
||||
* A separate namespace from the feed itself (`/api/cycle/feed/<token>`), so
|
||||
* neither the path redactor nor the public-path prefix can ever catch it.
|
||||
*
|
||||
* Auth: cookie session only, through `requireRecordAuth("manage", "cycle")`:
|
||||
* the owner, or a MANAGE delegate whose grant covers the cycle section. A
|
||||
* READ or WRITE delegate is refused. Turning it on also needs a fresh proof,
|
||||
* because the link outlives the session that made it. A delegate's feed is
|
||||
* re-checked against their grant on every fetch, and a handover claim ends it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
FEED_MINT_LIMIT_PER_HOUR,
|
||||
FEED_MINT_WINDOW_MS,
|
||||
cookieOnlyRefusal,
|
||||
feedExists,
|
||||
feedLinks,
|
||||
noLiveFeed,
|
||||
} from "@/lib/cycle/feed/manage";
|
||||
import {
|
||||
FEED_SUMMARY_SELECT,
|
||||
cycleFeedStatus,
|
||||
findLiveFeed,
|
||||
toFeedSummary,
|
||||
} from "@/lib/cycle/feed/status";
|
||||
import { mintFeedToken } from "@/lib/cycle/feed/token";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
destroyedDetails,
|
||||
overwriteDetails,
|
||||
} from "@/lib/sharing/audit-details";
|
||||
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireRecordAuth("manage", "cycle");
|
||||
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
|
||||
const { user } = auth;
|
||||
annotate({ action: { name: "cycle.feed.status" } });
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireRecordAuth("manage", "cycle");
|
||||
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
|
||||
const { user, actor } = auth;
|
||||
// The proof is the person at the keyboard, so it is the actor's session.
|
||||
await assertRecentCookieProof(actor, auth.session.id);
|
||||
annotate({ action: { name: "cycle.feed.create" } });
|
||||
|
||||
// The operator's external-access switch covers this credential too.
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403, {
|
||||
errorCode: "cycle.feed.api_disabled",
|
||||
});
|
||||
}
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle-feed:mint:${actor.id}`,
|
||||
FEED_MINT_LIMIT_PER_HOUR,
|
||||
FEED_MINT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many calendar links, try again later", 429, {
|
||||
errorCode: "cycle.feed.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
if (await findLiveFeed(user.id)) return feedExists();
|
||||
|
||||
const minted = mintFeedToken();
|
||||
let created;
|
||||
try {
|
||||
created = await prisma.cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
createdById: actor.id,
|
||||
tokenHash: minted.hash,
|
||||
tokenPrefix: minted.prefix,
|
||||
},
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
} catch (err) {
|
||||
// A second mint racing this one lost to the one-live-feed index.
|
||||
if (isP2002(err)) return feedExists();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditLog("cycle.feed.create", {
|
||||
userId: user.id,
|
||||
details: { feedId: created.id, tokenPrefix: created.tokenPrefix },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...feedLinks(resolveOrigin(request), minted.raw),
|
||||
feed: toFeedSummary(created),
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireRecordAuth("manage", "cycle");
|
||||
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
|
||||
const { user } = auth;
|
||||
annotate({ action: { name: "cycle.feed.update" } });
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = await parseJsonBody(request, cycleFeedOptionsPatchSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
const input = parsed.data;
|
||||
|
||||
// The fertile window is a conception-goal option and nothing else. Refused
|
||||
// here rather than stored and ignored, so a later goal change cannot switch
|
||||
// on something the person never chose under that goal.
|
||||
if (
|
||||
input.includeFertile === true &&
|
||||
gate.profile.goal !== "TRYING_TO_CONCEIVE"
|
||||
) {
|
||||
return apiError(
|
||||
"The fertile window is only offered when trying to conceive",
|
||||
422,
|
||||
{
|
||||
errorCode: "cycle.feed.fertile_goal",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
|
||||
const data = {
|
||||
...(input.horizonCycles !== undefined
|
||||
? { horizonCycles: input.horizonCycles }
|
||||
: {}),
|
||||
...(input.historyCycles !== undefined
|
||||
? { historyCycles: input.historyCycles }
|
||||
: {}),
|
||||
...(input.includeFertile !== undefined
|
||||
? { includeFertile: input.includeFertile }
|
||||
: {}),
|
||||
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
|
||||
};
|
||||
const updated = await prisma.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data,
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
|
||||
await auditLog("cycle.feed.update", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
feedId: live.id,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
horizonCycles: live.horizonCycles,
|
||||
historyCycles: live.historyCycles,
|
||||
includeFertile: live.includeFertile,
|
||||
titleMode: live.titleMode,
|
||||
},
|
||||
after: {
|
||||
horizonCycles: updated.horizonCycles,
|
||||
historyCycles: updated.historyCycles,
|
||||
includeFertile: updated.includeFertile,
|
||||
titleMode: updated.titleMode,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const auth = await requireRecordAuth("manage", "cycle");
|
||||
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
|
||||
const { user } = auth;
|
||||
annotate({ action: { name: "cycle.feed.revoke" } });
|
||||
|
||||
const live = await findLiveFeed(user.id);
|
||||
if (!live) return noLiveFeed();
|
||||
|
||||
await prisma.cycleCalendarFeed.update({
|
||||
where: { id: live.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// The link stops working on the provider's next poll. What the provider
|
||||
// already copied is beyond reach, which the settings card says before the
|
||||
// link is ever made.
|
||||
await auditLog("cycle.feed.revoke", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
feedId: live.id,
|
||||
...destroyedDetails({
|
||||
model: "CycleCalendarFeed",
|
||||
id: live.id,
|
||||
label: live.tokenPrefix,
|
||||
effectiveAt: live.createdAt,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `GET /api/cycle/feed/<hlf_token>[.ics]` — the subscribable cycle forecast
|
||||
* (#1350).
|
||||
*
|
||||
* Anonymous by design: a calendar provider polls this URL from its own
|
||||
* servers, so the `hlf_` path segment is the only credential. Rate-limited
|
||||
* before the lookup, one blunt 404 for every miss, every gate re-read on each
|
||||
* fetch. The pipeline and its reasoning live in `serveCycleFeed`; this file
|
||||
* only binds it to the route.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { serveCycleFeed } from "@/lib/cycle/feed/serve";
|
||||
|
||||
type RouteParams = { params: Promise<{ token: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { token } = await params;
|
||||
return serveCycleFeed(request, token);
|
||||
},
|
||||
);
|
||||
@@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrigin } from "@/lib/http/origin";
|
||||
import {
|
||||
createShareLinkSchema,
|
||||
SHARE_LINK_FORBIDDEN_LEAVES,
|
||||
@@ -41,23 +42,6 @@ import {
|
||||
} from "@/lib/clinician-share/passphrase";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The absolute origin to build the public share URL from. Prefers the
|
||||
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
|
||||
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
|
||||
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
|
||||
*/
|
||||
function resolveOrigin(request: NextRequest): string {
|
||||
const proto =
|
||||
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
|
||||
new URL(request.url).protocol.replace(":", "");
|
||||
const host =
|
||||
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("host") ||
|
||||
new URL(request.url).host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row selection every owner-facing projection reads. `_count.documents`
|
||||
* surfaces the size of the frozen document set without ever loading the ids
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card";
|
||||
import type { CycleFeedStatus } from "../use-cycle-feed";
|
||||
|
||||
function render(status: CycleFeedStatus) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.cycleFeedSubscription(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CycleCalendarFeedCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FEED = {
|
||||
tokenPrefix: "hlf_ab12",
|
||||
createdAt: "2026-11-01T10:00:00.000Z",
|
||||
lastFetchedAt: null,
|
||||
fetchCount: 0,
|
||||
lastFetcher: null,
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
};
|
||||
|
||||
describe("<CycleCalendarFeedCard>", () => {
|
||||
it("says what a provider keeps before it offers the button", () => {
|
||||
const html = render({
|
||||
feed: null,
|
||||
projectionState: null,
|
||||
fertileAvailable: false,
|
||||
});
|
||||
const disclosure = html.indexOf("stores these dates on its servers");
|
||||
const button = html.indexOf("Turn on subscription");
|
||||
expect(disclosure).toBeGreaterThan(-1);
|
||||
expect(button).toBeGreaterThan(disclosure);
|
||||
});
|
||||
|
||||
it("shows only the prefix of a live link, and no URL", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "OK",
|
||||
fertileAvailable: false,
|
||||
});
|
||||
expect(html).toContain("hlf_ab12");
|
||||
expect(html).not.toContain("/api/cycle/feed/");
|
||||
expect(html).toContain("Not fetched yet.");
|
||||
// The fertile option exists only for a conception goal.
|
||||
expect(html).not.toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("explains a shorter feed in the muted tone", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "LATE",
|
||||
fertileAvailable: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="cycle-feed-state"');
|
||||
expect(html).toContain("later than expected");
|
||||
expect(html).toContain("Fertile window");
|
||||
});
|
||||
});
|
||||
@@ -104,3 +104,59 @@ describe("<PredictionsPanel>", () => {
|
||||
expect(html).toContain("Prediction is off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<PredictionsPanel> later cycles (#1350)", () => {
|
||||
const projection = [
|
||||
{
|
||||
k: 1,
|
||||
start: "2026-07-01",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-06-29",
|
||||
bandEnd: "2026-07-03",
|
||||
},
|
||||
{
|
||||
k: 2,
|
||||
start: "2026-07-29",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-07-26",
|
||||
bandEnd: "2026-08-01",
|
||||
},
|
||||
{
|
||||
k: 3,
|
||||
start: "2026-08-26",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-08-22",
|
||||
bandEnd: "2026-08-30",
|
||||
},
|
||||
];
|
||||
|
||||
it("lists the cycles after the next one, as the server's windows", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={projection}
|
||||
projectionState="OK"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection"');
|
||||
expect(html).toContain("Later cycles");
|
||||
// Two later rows; cycle 1 is the next-period row above them.
|
||||
expect(html.match(/Around /g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("states a late period instead of listing later cycles", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={[]}
|
||||
projectionState="LATE"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection-late"');
|
||||
expect(html).not.toContain('data-slot="cycle-projection"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarSync, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
useCycleFeedStatus,
|
||||
useMintCycleFeed,
|
||||
useRevokeCycleFeed,
|
||||
useUpdateCycleFeed,
|
||||
type CycleFeedMinted,
|
||||
type CycleFeedOptionsPatch,
|
||||
type CycleFeedStatus,
|
||||
type FeedTitleMode,
|
||||
} from "./use-cycle-feed";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription card on the cycle settings tab.
|
||||
*
|
||||
* Off by default, and the disclosure comes before the button: a calendar
|
||||
* provider keeps what it fetched, and turning the link off later reaches none
|
||||
* of it. The link itself is shown once, right after it is made; after that the
|
||||
* card knows only its prefix. Rendered only for the owner and a MANAGE
|
||||
* delegate (the tab's own gate); the routes refuse everyone else regardless.
|
||||
*/
|
||||
|
||||
const HORIZONS = [1, 3, 6] as const;
|
||||
const HISTORIES = [0, 3, 6, 12] as const;
|
||||
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
|
||||
|
||||
/** A long URL with its middle elided, so both ends stay recognisable. */
|
||||
function middleTruncate(url: string, keep = 28): string {
|
||||
return url.length <= keep * 2 + 1
|
||||
? url
|
||||
: `${url.slice(0, keep)}…${url.slice(-keep / 2)}`;
|
||||
}
|
||||
|
||||
export function CycleCalendarFeedCard() {
|
||||
const { t } = useTranslations();
|
||||
const status = useCycleFeedStatus();
|
||||
const proof = useRecentProof();
|
||||
const mint = useMintCycleFeed(proof);
|
||||
const [minted, setMinted] = useState<CycleFeedMinted | null>(null);
|
||||
const [mintError, setMintError] = useState<string | null>(null);
|
||||
|
||||
async function handleMint(kind: "create" | "rotate") {
|
||||
setMintError(null);
|
||||
try {
|
||||
setMinted(await mint.mutateAsync(kind));
|
||||
} catch (err) {
|
||||
setMintError(recentProofErrorMessage(err, t("cycle.feed.error")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="cycle-calendar-feed">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarSync}
|
||||
title={t("cycle.feed.title")}
|
||||
description={t("cycle.feed.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : status.isLoading || !status.data ? (
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : status.data.feed === null ? (
|
||||
<OffState
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onTurnOn={() => void handleMint("create")}
|
||||
/>
|
||||
) : (
|
||||
<OnState
|
||||
status={status.data}
|
||||
minted={minted}
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onRotate={() => void handleMint("rotate")}
|
||||
onRevoked={() => setMinted(null)}
|
||||
/>
|
||||
)}
|
||||
{proof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function OffState({
|
||||
pending,
|
||||
error,
|
||||
onTurnOn,
|
||||
}: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onTurnOn: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">{t("cycle.feed.disclosure")}</p>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={onTurnOn}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.feed.turnOn")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OnState({
|
||||
status,
|
||||
minted,
|
||||
pending,
|
||||
error,
|
||||
onRotate,
|
||||
onRevoked,
|
||||
}: {
|
||||
status: CycleFeedStatus;
|
||||
minted: CycleFeedMinted | null;
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onRotate: () => void;
|
||||
onRevoked: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const revoke = useRevokeCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const state = status.projectionState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{minted ? (
|
||||
<LinkPanel minted={minted} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })}
|
||||
</p>
|
||||
)}
|
||||
<FetchLine status={status} />
|
||||
{state === "LEARNING" || state === "LATE" || state === "OFF" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-feed-state"
|
||||
>
|
||||
{t(`cycle.projection.state.${state}`)}
|
||||
</p>
|
||||
) : null}
|
||||
<Separator />
|
||||
<FeedOptions status={status} />
|
||||
<HelpList />
|
||||
{error || revoke.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error ?? t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions className="flex-col items-stretch sm:flex-row sm:items-center">
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.turnOff")}
|
||||
title={t("cycle.feed.turnOffTitle")}
|
||||
body={t("cycle.feed.turnOffBody")}
|
||||
confirmLabel={t("cycle.feed.turnOff")}
|
||||
onConfirm={() => revoke.mutate(undefined, { onSuccess: onRevoked })}
|
||||
pending={revoke.isPending}
|
||||
variant="destructive"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.rotate")}
|
||||
title={t("cycle.feed.rotateTitle")}
|
||||
body={t("cycle.feed.rotateBody")}
|
||||
confirmLabel={t("cycle.feed.rotate")}
|
||||
onConfirm={onRotate}
|
||||
pending={pending}
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link, the one time it exists in the client. */
|
||||
function LinkPanel({ minted }: { minted: CycleFeedMinted }) {
|
||||
const { t } = useTranslations();
|
||||
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(minted.url);
|
||||
setCopy("copied");
|
||||
} catch {
|
||||
setCopy("failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="cycle-feed-link">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-url">{t("cycle.feed.linkLabel")}</Label>
|
||||
<code
|
||||
id="cycle-feed-url"
|
||||
className="bg-muted block rounded-md px-3 py-2 text-sm break-all"
|
||||
title={minted.url}
|
||||
>
|
||||
{middleTruncate(minted.url)}
|
||||
</code>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.feed.linkOnce")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t("cycle.feed.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
<a href={minted.webcalUrl}>{t("cycle.feed.openInCalendar")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{copy === "copied"
|
||||
? t("cycle.feed.copied")
|
||||
: copy === "failed"
|
||||
? t("cycle.feed.copyFailed")
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FetchLine({ status }: { status: CycleFeedStatus }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const feed = status.feed!;
|
||||
if (feed.lastFetchedAt === null) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.notFetched")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const when = formatRelativeTime(feed.lastFetchedAt, t, locale);
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.lastFetched", {
|
||||
when,
|
||||
fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedOptions({ status }: { status: CycleFeedStatus }) {
|
||||
const { t } = useTranslations();
|
||||
const update = useUpdateCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-horizon">{t("cycle.feed.horizon")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-horizon"
|
||||
value={String(feed.horizonCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ horizonCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HORIZONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-history">{t("cycle.feed.history")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-history"
|
||||
value={String(feed.historyCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ historyCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HISTORIES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n === 0 ? t("cycle.feed.historyNone") : n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-title">{t("cycle.feed.titles")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-title"
|
||||
value={feed.titleMode}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) =>
|
||||
save({ titleMode: e.target.value as FeedTitleMode })
|
||||
}
|
||||
>
|
||||
{TITLE_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`cycle.feed.titleMode.${m}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
{status.fertileAvailable ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="cycle-feed-fertile" className="text-sm">
|
||||
{t("cycle.feed.fertile")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.fertileDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cycle-feed-fertile"
|
||||
checked={feed.includeFertile}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) => save({ includeFertile: v })}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{update.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpList() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">{t("cycle.feed.help.title")}</p>
|
||||
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">
|
||||
<li>{t("cycle.feed.help.google")}</li>
|
||||
<li>{t("cycle.feed.help.apple")}</li>
|
||||
<li>{t("cycle.feed.help.outlook")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import type { CycleProfileDTO } from "./types";
|
||||
|
||||
/**
|
||||
* The cycle page's settings tab: the profile form, then the calendar
|
||||
* subscription (#1350). The form remounts on a profile refetch (`key`), the
|
||||
* subscription card keeps its own state, including a link it just showed.
|
||||
*/
|
||||
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CycleSettings key={profile.updatedAt} profile={profile} />
|
||||
<CycleCalendarFeedCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { CycleSettingsTab } from "./cycle-settings-tab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
@@ -417,6 +417,8 @@ export function CycleView() {
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data?.data}
|
||||
projection={calendar.data?.projection}
|
||||
projectionState={calendar.data?.projectionState ?? null}
|
||||
/>
|
||||
<CycleHistoryChart
|
||||
history={history.data?.data}
|
||||
@@ -471,10 +473,7 @@ export function CycleView() {
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
<CycleSettingsTab profile={profileQuery.data} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
import type {
|
||||
CyclePrediction,
|
||||
CycleHistoryResponse,
|
||||
ProjectedCycle,
|
||||
ProjectionState,
|
||||
} from "./types";
|
||||
import { UpcomingCycles } from "./upcoming-cycles";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/**
|
||||
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** #1350 — the server's projection of the cycles after the next one. */
|
||||
projection?: readonly ProjectedCycle[];
|
||||
projectionState?: ProjectionState | null;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
projection = [],
|
||||
projectionState = null,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -94,6 +105,7 @@ export function PredictionsPanel({
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -119,6 +131,7 @@ export function PredictionsPanel({
|
||||
value={formatDate(prediction.predictedOvulation, fmt)}
|
||||
/>
|
||||
) : null}
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -114,10 +114,28 @@ export interface CalendarResponse {
|
||||
* phase band — render the calm "learning your cycle" state over the grid.
|
||||
*/
|
||||
stillLearning: boolean;
|
||||
/**
|
||||
* #1350 — the next cycles with widening bands, resolved server-side (the
|
||||
* calendar feed serves the same list). Render as given; never extrapolate.
|
||||
*/
|
||||
projection: ProjectedCycle[];
|
||||
projectionState: ProjectionState;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
|
||||
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
|
||||
|
||||
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
|
||||
export interface ProjectedCycle {
|
||||
k: number;
|
||||
start: string;
|
||||
periodLength: number;
|
||||
bandStart: string;
|
||||
bandEnd: string;
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import type { ProjectedCycle, ProjectionState } from "./types";
|
||||
|
||||
/**
|
||||
* #1350 — the periods after the next one, each as the window its start may
|
||||
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
|
||||
* `projection`, the same list the calendar feed serves); this only renders
|
||||
* them. The band widens from one row to the next, and the server stops the
|
||||
* list where a date would be a guess.
|
||||
*
|
||||
* The next period itself is the row above this one in the predictions card,
|
||||
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
|
||||
* late period is a fact about the calendar, not a warning.
|
||||
*/
|
||||
export function UpcomingCycles({
|
||||
projection,
|
||||
state,
|
||||
}: {
|
||||
projection: readonly ProjectedCycle[];
|
||||
state: ProjectionState | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (state === "LATE") {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-projection-late"
|
||||
>
|
||||
{t("cycle.projection.state.LATE")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const later = projection.filter((c) => c.k >= 2);
|
||||
if (later.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="cycle-projection">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.projection.title")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{later.map((c) => (
|
||||
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cycle.projection.around", {
|
||||
date: fmt.dateShortSmartCalendar(c.start),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground ml-auto font-medium tabular-nums">
|
||||
{t("cycle.predictions.nextPeriodWindow", {
|
||||
low: fmt.dateShortSmartCalendar(c.bandStart),
|
||||
high: fmt.dateShortSmartCalendar(c.bandEnd),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription's read and write hooks.
|
||||
*
|
||||
* Every value the card shows is the server's: the status read carries the
|
||||
* options, the fetch statistics as a calendar-app family, and the projection
|
||||
* state that explains a shorter feed. The URL exists in the client only in
|
||||
* the one mint or rotate response that returns it, held in the card's state
|
||||
* and gone when the card unmounts.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { ProjectionState } from "./types";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
lastFetcher: FeedFetcher | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
feed: CycleFeedSummary | null;
|
||||
projectionState: ProjectionState | null;
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface CycleFeedMinted {
|
||||
url: string;
|
||||
webcalUrl: string;
|
||||
feed: CycleFeedSummary;
|
||||
}
|
||||
|
||||
export type CycleFeedOptionsPatch = Partial<
|
||||
Pick<
|
||||
CycleFeedSummary,
|
||||
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
|
||||
>
|
||||
>;
|
||||
|
||||
const PATH = "/api/cycle/feed-subscription";
|
||||
|
||||
export function useCycleFeedStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleFeedSubscription(),
|
||||
queryFn: () => apiGet<CycleFeedStatus>(PATH),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateStatus() {
|
||||
const qc = useQueryClient();
|
||||
return () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
|
||||
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
|
||||
* `proof.run` opens the re-proof dialog and retries.
|
||||
*/
|
||||
export function useMintCycleFeed(proof: {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
}) {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (kind: "create" | "rotate") =>
|
||||
kind === "create"
|
||||
? proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
|
||||
)
|
||||
: proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
|
||||
),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (patch: CycleFeedOptionsPatch) =>
|
||||
apiPatch<CycleFeedStatus>(PATH, patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -101,6 +101,16 @@ describe("GlitchTip forwarder — path-segment secret redaction", () => {
|
||||
expect(input.url).not.toContain(shareToken);
|
||||
});
|
||||
|
||||
it("does not forward a cycle calendar-feed token verbatim", async () => {
|
||||
// Built at run time so no token-shaped literal sits in the source.
|
||||
const feedToken = `hlf_${"3c".repeat(24)}`;
|
||||
const input = await forwardedPayload(
|
||||
`http://localhost/api/cycle/feed/${feedToken}.ics`,
|
||||
);
|
||||
expect(input.url).not.toContain(feedToken);
|
||||
expect(input.url).toContain("/api/cycle/feed/");
|
||||
});
|
||||
|
||||
it("still strips the query string", async () => {
|
||||
const input = await forwardedPayload(
|
||||
"http://localhost/api/auth/oidc/callback?code=abc123&state=xyz789",
|
||||
|
||||
@@ -14,6 +14,10 @@ describe("looksSecretShaped", () => {
|
||||
["an MCP authorization code", "hlac_eyJ0eXAi"],
|
||||
["an MCP refresh token", "hlrt_eyJ0eXAi"],
|
||||
["an MCP client id", "hlc_eyJ0eXAi"],
|
||||
[
|
||||
"a cycle calendar-feed token",
|
||||
`url /api/cycle/feed/hlf_${"9e".repeat(24)}.ics`,
|
||||
],
|
||||
["an OpenAI key", "echoed sk-1234567890"],
|
||||
["an Anthropic key", "sk-ant-api03-xyz12345"],
|
||||
])("matches %s", (_label, text) => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* #1350 — the RFC 5545 writer behind the calendar feed.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
escapeText,
|
||||
eventUid,
|
||||
foldLine,
|
||||
icsContentHash,
|
||||
icsStamp,
|
||||
renderIcs,
|
||||
type IcsCalendar,
|
||||
} from "../ics";
|
||||
|
||||
const CAL: IcsCalendar = {
|
||||
name: "HealthLog",
|
||||
events: [
|
||||
{
|
||||
uid: "abc@healthlog.invalid",
|
||||
start: "2026-11-10",
|
||||
endExclusive: "2026-11-17",
|
||||
summary: "●",
|
||||
description: "±2 days",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STAMP = new Date("2026-11-06T09:30:00.000Z");
|
||||
|
||||
describe("renderIcs", () => {
|
||||
it("matches the golden body", () => {
|
||||
expect(renderIcs(CAL, STAMP)).toBe(
|
||||
[
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//HealthLog//Calendar feed//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"X-WR-CALNAME:HealthLog",
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
||||
"X-PUBLISHED-TTL:PT6H",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:abc@healthlog.invalid",
|
||||
"DTSTAMP:20261106T093000Z",
|
||||
"LAST-MODIFIED:20261106T093000Z",
|
||||
"DTSTART;VALUE=DATE:20261110",
|
||||
"DTEND;VALUE=DATE:20261117",
|
||||
"SUMMARY:●",
|
||||
"DESCRIPTION:±2 days",
|
||||
"TRANSP:TRANSPARENT",
|
||||
"CLASS:PRIVATE",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("ends every line with CRLF and carries no bare LF", () => {
|
||||
const body = renderIcs(CAL, STAMP);
|
||||
expect(body.endsWith("\r\n")).toBe(true);
|
||||
expect(body.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
|
||||
it("writes all-day events with an exclusive DTEND and no time zone", () => {
|
||||
const body = renderIcs(CAL, STAMP);
|
||||
expect(body).toContain("DTSTART;VALUE=DATE:20261110");
|
||||
expect(body).toContain("DTEND;VALUE=DATE:20261117");
|
||||
expect(body).not.toContain("VTIMEZONE");
|
||||
expect(body).not.toContain("TZID");
|
||||
});
|
||||
|
||||
it("refuses a malformed day rather than writing a broken calendar", () => {
|
||||
expect(() =>
|
||||
renderIcs(
|
||||
{ name: "x", events: [{ ...CAL.events[0], start: "2026-11-1" }] },
|
||||
STAMP,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeText", () => {
|
||||
it("escapes backslash, semicolon, comma and newline", () => {
|
||||
expect(escapeText("a\\b;c,d\ne")).toBe("a\\\\b\\;c\\,d\\ne");
|
||||
expect(escapeText("x\r\ny")).toBe("x\\ny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldLine", () => {
|
||||
const octets = (s: string) => Buffer.byteLength(s, "utf8");
|
||||
|
||||
it("leaves a short line alone", () => {
|
||||
expect(foldLine("SUMMARY:short")).toBe("SUMMARY:short");
|
||||
});
|
||||
|
||||
it("folds at 75 octets with a leading space on each continuation", () => {
|
||||
const folded = foldLine(`DESCRIPTION:${"a".repeat(200)}`);
|
||||
const lines = folded.split("\r\n");
|
||||
expect(lines.length).toBeGreaterThan(2);
|
||||
for (const line of lines) expect(octets(line)).toBeLessThanOrEqual(75);
|
||||
for (const line of lines.slice(1)) expect(line.startsWith(" ")).toBe(true);
|
||||
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
|
||||
`DESCRIPTION:${"a".repeat(200)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("never splits a multibyte character", () => {
|
||||
// Cyrillic and Hangul are two and three octets each; a byte-counted cut
|
||||
// would land inside one of them sooner or later.
|
||||
const value = `SUMMARY:${"Месячные 생리 ".repeat(12)}`;
|
||||
const lines = foldLine(value).split("\r\n");
|
||||
for (const line of lines) {
|
||||
expect(octets(line)).toBeLessThanOrEqual(75);
|
||||
// A cut through a sequence would leave a replacement character after a
|
||||
// round trip through bytes.
|
||||
expect(Buffer.from(line, "utf8").toString("utf8")).toBe(line);
|
||||
expect(line).not.toContain("�");
|
||||
}
|
||||
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
|
||||
value,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventUid", () => {
|
||||
it("is stable for a feed and a slot, and unrelated across feeds", () => {
|
||||
const a = eventUid("feed-1", "pred:1");
|
||||
expect(eventUid("feed-1", "pred:1")).toBe(a);
|
||||
expect(eventUid("feed-1", "pred:2")).not.toBe(a);
|
||||
expect(eventUid("feed-2", "pred:1")).not.toBe(a);
|
||||
expect(a).toMatch(/^[a-z2-7]{32}@healthlog\.invalid$/);
|
||||
});
|
||||
|
||||
it("carries neither the feed id nor the slot", () => {
|
||||
const uid = eventUid("cmfeedid123", "obs:cmcycleid456");
|
||||
expect(uid).not.toContain("cmfeedid123");
|
||||
expect(uid).not.toContain("cmcycleid456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("icsContentHash", () => {
|
||||
it("does not depend on the stamp, only on the content", () => {
|
||||
expect(icsContentHash(CAL)).toBe(icsContentHash({ ...CAL }));
|
||||
expect(icsContentHash(CAL)).not.toBe(
|
||||
icsContentHash({ ...CAL, events: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats a stamp as basic UTC", () => {
|
||||
expect(icsStamp(STAMP)).toBe("20261106T093000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* #1350 — building a feed's body from the record's rows: the record's own
|
||||
* day, zone and language; a hash that moves only with the content; and the
|
||||
* goal and cold-start gates on the fertile window.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const localeSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: localeSpy,
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { renderIcs } from "@/lib/cycle/ics";
|
||||
|
||||
import { buildCycleFeed, feedLocale } from "../build";
|
||||
import { DISCREET_SUMMARY } from "../events";
|
||||
import { FEED, SUBJECT, profileRow, regularCycles } from "./fixtures";
|
||||
|
||||
const db = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
// The last start lands on 2026-10-20, so the next period is due mid-November.
|
||||
const FIRST = "2025-11-18";
|
||||
const NOW = new Date("2026-11-06T12:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleProfile.findUnique.mockResolvedValue(profileRow());
|
||||
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
|
||||
db.cycleDayLog.findMany.mockResolvedValue([]);
|
||||
db.measurement.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("buildCycleFeed", () => {
|
||||
it("serves the projected periods with discreet titles by default", async () => {
|
||||
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(built.projectionState).toBe("OK");
|
||||
expect(built.calendar.name).toBe("HealthLog");
|
||||
expect(built.calendar.events.length).toBe(3);
|
||||
for (const e of built.calendar.events) {
|
||||
expect(e.summary).toBe(DISCREET_SUMMARY);
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes the same inputs on the same day to the same value", async () => {
|
||||
const a = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const b = await buildCycleFeed(
|
||||
FEED,
|
||||
SUBJECT,
|
||||
new Date("2026-11-06T18:00:00.000Z"),
|
||||
);
|
||||
expect(b.contentHash).toBe(a.contentHash);
|
||||
// The events keep their UIDs across rebuilds.
|
||||
expect(b.calendar.events.map((e) => e.uid)).toEqual(
|
||||
a.calendar.events.map((e) => e.uid),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the hash when a cycle is deleted", async () => {
|
||||
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(
|
||||
regularCycles(FIRST).slice(0, -1),
|
||||
);
|
||||
const after = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(after.contentHash).not.toBe(before.contentHash);
|
||||
});
|
||||
|
||||
it("moves the hash when a day roll takes the forecast past its band", async () => {
|
||||
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const late = await buildCycleFeed(
|
||||
FEED,
|
||||
SUBJECT,
|
||||
new Date("2026-12-15T12:00:00.000Z"),
|
||||
);
|
||||
expect(late.projectionState).toBe("LATE");
|
||||
expect(late.calendar.events).toEqual([]);
|
||||
expect(late.contentHash).not.toBe(before.contentHash);
|
||||
});
|
||||
|
||||
it("reads today in the record's zone, not in UTC", async () => {
|
||||
// Find the next period's band end, then ask at 23:30 UTC on that day: in
|
||||
// Berlin it is already the following day, which is past the band.
|
||||
const onTime = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const first = onTime.calendar.events[0];
|
||||
// DTEND is the band end plus the period length; recover the band end.
|
||||
const bandEnd = addDays(first.endExclusive, -5);
|
||||
const lateEvening = new Date(`${bandEnd}T23:30:00.000Z`);
|
||||
|
||||
const utc = await buildCycleFeed(
|
||||
FEED,
|
||||
{ ...SUBJECT, timezone: "UTC" },
|
||||
lateEvening,
|
||||
);
|
||||
const berlin = await buildCycleFeed(FEED, SUBJECT, lateEvening);
|
||||
expect(utc.projectionState).toBe("OK");
|
||||
expect(berlin.projectionState).toBe("LATE");
|
||||
});
|
||||
|
||||
it("names nothing when the prediction is switched off, and history still works", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("GENERAL_HEALTH", { predictionEnabled: false }),
|
||||
);
|
||||
const off = await buildCycleFeed(
|
||||
{ ...FEED, historyCycles: 3 },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(off.projectionState).toBe("OFF");
|
||||
expect(off.calendar.events).toHaveLength(3);
|
||||
const starts = off.calendar.events.map((e) => e.start);
|
||||
expect(starts.at(-1)).toBe("2026-10-20");
|
||||
});
|
||||
|
||||
it("treats raw-chart mode as no forecast", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("GENERAL_HEALTH", { rawChartMode: true }),
|
||||
);
|
||||
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(built.projectionState).toBe("OFF");
|
||||
expect(built.calendar.events).toEqual([]);
|
||||
});
|
||||
|
||||
describe("the fertile window", () => {
|
||||
it("is added for TRYING_TO_CONCEIVE when asked for", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(
|
||||
built.calendar.events.filter(
|
||||
(e) => e.summary === "Fertile window (estimate)",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is never added for AVOID_PREGNANCY, whatever the option says", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("AVOID_PREGNANCY"),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.events.map((e) => e.summary)).not.toContain(
|
||||
"Fertile window (estimate)",
|
||||
);
|
||||
expect(built.calendar.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is withheld while the engine is still learning", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(
|
||||
regularCycles("2026-09-22", 3),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(built.projectionState).toBe("LEARNING");
|
||||
expect(built.calendar.events.map((e) => e.summary)).toEqual([
|
||||
"Period (estimate)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the record's stored language for descriptive titles", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
{ ...SUBJECT, locale: "de" },
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.name).toBe("Zyklus (HealthLog)");
|
||||
expect(built.calendar.events[0].summary).toBe("Periode (Schätzung)");
|
||||
});
|
||||
|
||||
it("falls back to the instance default for a null locale and never asks the request", async () => {
|
||||
expect(feedLocale(null)).toBe("en");
|
||||
expect(feedLocale("xx")).toBe("en");
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
{ ...SUBJECT, locale: null },
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.events[0].summary).toBe("Period (estimate)");
|
||||
// A provider's Accept-Language must never reach the choice.
|
||||
expect(localeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the descriptive description with the likely start and the band", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
expect(body).toMatch(
|
||||
/DESCRIPTION:Most likely start [^\\\r]+\\, ±\d+ days?\r\n/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Shared rows for the calendar-feed tests: a regular record with a year of
|
||||
* 28-day cycles, mocked through the same Prisma calls the loader makes.
|
||||
*/
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
|
||||
export function regularCycles(first: string, count = 13, length = 28) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const startDate = addDays(first, i * length);
|
||||
out.push({
|
||||
id: `cycle-${i}`,
|
||||
userId: "subject",
|
||||
startDate,
|
||||
endDate: i < count - 1 ? addDays(startDate, length) : null,
|
||||
periodEndDate: i % 2 === 0 ? addDays(startDate, 4) : null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
deletedAt: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function profileRow(goal = "GENERAL_HEALTH", overrides = {}) {
|
||||
return {
|
||||
id: "profile",
|
||||
userId: "subject",
|
||||
goal,
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
secondarySymptom: "MUCUS",
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
discreetNotifications: false,
|
||||
sensitiveCategoryEncryption: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const FEED = {
|
||||
id: "feed-1",
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
};
|
||||
|
||||
export const SUBJECT = {
|
||||
id: "subject",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* #1350 — the anonymous feed fetch: the limit before any lookup, one 404 for
|
||||
* every miss, every gate re-read per fetch, and the ETag / 304 path.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleCalendarFeed: { findFirst: vi.fn(), update: vi.fn() },
|
||||
user: { findFirst: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
|
||||
const limits = vi.hoisted(() => ({
|
||||
surface: vi.fn(),
|
||||
token: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: limits.surface,
|
||||
checkRateLimit: limits.token,
|
||||
rateLimitHeaders: () => ({ "Retry-After": "60" }),
|
||||
}));
|
||||
|
||||
const gates = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
cycle: vi.fn(),
|
||||
grant: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: gates.api }));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ isCycleAvailableForUser: gates.cycle }));
|
||||
vi.mock("@/lib/sharing/grant-access", async (orig) => ({
|
||||
...(await orig<typeof import("@/lib/sharing/grant-access")>()),
|
||||
findActiveGrant: gates.grant,
|
||||
}));
|
||||
|
||||
const built = vi.hoisted(() => ({ build: vi.fn() }));
|
||||
vi.mock("../build", () => ({ buildCycleFeed: built.build }));
|
||||
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
|
||||
|
||||
import { serveCycleFeed } from "../serve";
|
||||
|
||||
const TOKEN = `hlf_${"ab".repeat(24)}`;
|
||||
const ALLOWED = { allowed: true, limit: 1, remaining: 1, resetAt: new Date() };
|
||||
|
||||
const FEED_ROW = {
|
||||
id: "feed-1",
|
||||
userId: "subject",
|
||||
createdById: "subject",
|
||||
bodyHash: "old-hash",
|
||||
contentChangedAt: new Date("2026-11-01T00:00:00Z"),
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET",
|
||||
};
|
||||
|
||||
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
req: new NextRequest(`http://localhost/api/cycle/feed/${path}`, {
|
||||
headers,
|
||||
}),
|
||||
segment: path,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFeed(path?: string, headers?: Record<string, string>) {
|
||||
const { req, segment } = request(path, headers);
|
||||
const res = await serveCycleFeed(req, segment);
|
||||
await settleBackgroundTasks();
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(40));
|
||||
limits.surface.mockResolvedValue({ ...ALLOWED, ip: "203.0.113.7" });
|
||||
limits.token.mockResolvedValue(ALLOWED);
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue(FEED_ROW);
|
||||
db.cycleCalendarFeed.update.mockResolvedValue({});
|
||||
db.user.findFirst.mockResolvedValue({
|
||||
id: "subject",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
});
|
||||
gates.api.mockResolvedValue(true);
|
||||
gates.cycle.mockResolvedValue(true);
|
||||
gates.grant.mockResolvedValue(null);
|
||||
built.build.mockResolvedValue({
|
||||
calendar: {
|
||||
name: "HealthLog",
|
||||
events: [
|
||||
{
|
||||
uid: "u@healthlog.invalid",
|
||||
start: "2026-11-15",
|
||||
endExclusive: "2026-11-22",
|
||||
summary: "●",
|
||||
},
|
||||
],
|
||||
},
|
||||
contentHash: "new-hash",
|
||||
projectionState: "OK",
|
||||
});
|
||||
});
|
||||
|
||||
async function expectBlunt404(res: Response) {
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ error: "Not found" });
|
||||
expect(body.meta?.errorCode).toBe("cycle.feed.notFound");
|
||||
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
||||
}
|
||||
|
||||
describe("serveCycleFeed", () => {
|
||||
it("serves the calendar with its validator and privacy headers", async () => {
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe(
|
||||
"text/calendar; charset=utf-8",
|
||||
);
|
||||
expect(res.headers.get("cache-control")).toBe(
|
||||
"private, no-cache, max-age=0",
|
||||
);
|
||||
expect(res.headers.get("etag")).toBe('"new-hash"');
|
||||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(await res.text()).toContain("BEGIN:VCALENDAR");
|
||||
});
|
||||
|
||||
it("looks the token up by its HMAC, live rows only", async () => {
|
||||
await fetchFeed();
|
||||
expect(db.cycleCalendarFeed.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { tokenHash: hashToken(TOKEN), revokedAt: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the anonymous limit before any lookup", async () => {
|
||||
limits.surface.mockResolvedValue({ ...ALLOWED, allowed: false, ip: null });
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(429);
|
||||
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
|
||||
expect(limits.surface).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"cycle-feed",
|
||||
120,
|
||||
3_600_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("throttles one link through its own bucket", async () => {
|
||||
limits.token.mockResolvedValue({ ...ALLOWED, allowed: false });
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(429);
|
||||
expect(limits.token).toHaveBeenCalledWith(
|
||||
`cycle-feed:${hashToken(TOKEN)}`,
|
||||
30,
|
||||
3_600_000,
|
||||
);
|
||||
expect(built.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers a malformed token without a lookup", async () => {
|
||||
await expectBlunt404(await fetchFeed("hlf_short.ics"));
|
||||
await expectBlunt404(await fetchFeed(`hlk_${"ab".repeat(24)}`));
|
||||
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"unknown or revoked",
|
||||
() => db.cycleCalendarFeed.findFirst.mockResolvedValue(null),
|
||||
],
|
||||
[
|
||||
"the record pending deletion or gone",
|
||||
() => db.user.findFirst.mockResolvedValue(null),
|
||||
],
|
||||
[
|
||||
"the operator's external-access switch off",
|
||||
() => gates.api.mockResolvedValue(false),
|
||||
],
|
||||
["cycle tracking off", () => gates.cycle.mockResolvedValue(false)],
|
||||
])("answers the same 404 for %s", async (_label, arrange) => {
|
||||
arrange();
|
||||
await expectBlunt404(await fetchFeed());
|
||||
expect(built.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("a feed a delegate minted", () => {
|
||||
beforeEach(() => {
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
createdById: "manager",
|
||||
});
|
||||
});
|
||||
|
||||
const grant = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "g",
|
||||
access: "MANAGE",
|
||||
acceptedAt: new Date("2026-01-01"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
scopeJson: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("is served while the manager's MANAGE grant over cycle is live", async () => {
|
||||
gates.grant.mockResolvedValue(grant());
|
||||
expect((await fetchFeed()).status).toBe(200);
|
||||
expect(gates.grant).toHaveBeenCalledWith({
|
||||
grantorId: "subject",
|
||||
granteeId: "manager",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no live grant", null],
|
||||
["a WRITE grant", grant({ access: "WRITE" })],
|
||||
["a READ grant", grant({ access: "READ" })],
|
||||
[
|
||||
"a grant scoped away from cycle",
|
||||
grant({ scopeJson: ["measurements"] }),
|
||||
],
|
||||
])("is a blunt 404 with %s", async (_label, row) => {
|
||||
gates.grant.mockResolvedValue(row);
|
||||
await expectBlunt404(await fetchFeed());
|
||||
});
|
||||
|
||||
it("is a blunt 404 once the manager's account is gone", async () => {
|
||||
gates.grant.mockResolvedValue(grant());
|
||||
db.user.findFirst.mockImplementation(
|
||||
async (args: { where: { id: string } }) =>
|
||||
args.where.id === "manager"
|
||||
? null
|
||||
: { id: "subject", timezone: null, locale: null },
|
||||
);
|
||||
await expectBlunt404(await fetchFeed());
|
||||
});
|
||||
});
|
||||
|
||||
it("moves the stamp and the hash only when the content moved", async () => {
|
||||
await fetchFeed();
|
||||
expect(db.cycleCalendarFeed.update).toHaveBeenCalledWith({
|
||||
where: { id: "feed-1" },
|
||||
data: expect.objectContaining({
|
||||
bodyHash: "new-hash",
|
||||
contentChangedAt: expect.any(Date),
|
||||
fetchCount: { increment: 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
bodyHash: "new-hash",
|
||||
});
|
||||
db.cycleCalendarFeed.update.mockResolvedValue({});
|
||||
const res = await fetchFeed();
|
||||
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
|
||||
expect(data).not.toHaveProperty("bodyHash");
|
||||
expect(data).not.toHaveProperty("contentChangedAt");
|
||||
expect(await res.text()).toContain("DTSTAMP:20261101T000000Z");
|
||||
});
|
||||
|
||||
it("answers 304 to a matching If-None-Match and still counts the fetch", async () => {
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
bodyHash: "new-hash",
|
||||
});
|
||||
const res = await fetchFeed(undefined, { "if-none-match": '"new-hash"' });
|
||||
expect(res.status).toBe(304);
|
||||
expect(res.headers.get("etag")).toBe('"new-hash"');
|
||||
expect(await res.text()).toBe("");
|
||||
expect(db.cycleCalendarFeed.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the User-Agent short", async () => {
|
||||
await fetchFeed(undefined, {
|
||||
"user-agent": `Google-Calendar-Importer ${"x".repeat(200)}`,
|
||||
});
|
||||
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
|
||||
expect(data.lastFetcherUa.length).toBe(80);
|
||||
});
|
||||
|
||||
it("does not fail the fetch when the statistics write fails", async () => {
|
||||
db.cycleCalendarFeed.update.mockRejectedValue(new Error("db down"));
|
||||
expect((await fetchFeed()).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Build one calendar feed's body from the record's rows, live (#1350).
|
||||
*
|
||||
* Nothing is read from the persisted `CyclePrediction`: it can be a day
|
||||
* stale, and the feed is computed on every fetch precisely so HealthLog stays
|
||||
* the only source of truth. The day-log window is the calendar's default
|
||||
* view, so the feed agrees with what the calendar page shows.
|
||||
*
|
||||
* `today`, the time zone and the language are the RECORD's stored settings,
|
||||
* never the request's. A calendar provider fetches from a data centre, in a
|
||||
* zone and with an Accept-Language that have nothing to do with the person.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
defaultCalendarDayLogStart,
|
||||
loadCycleEngineInputs,
|
||||
loadFlowDays,
|
||||
} from "@/lib/cycle/load-inputs";
|
||||
import {
|
||||
gatedPrediction,
|
||||
toCycleInputs,
|
||||
toProfileInput,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import { goalAllowsFertileWindow, toCyclePredictionDTO } from "@/lib/cycle/dto";
|
||||
import { projectCycles, type ProjectionState } from "@/lib/cycle/projection";
|
||||
import type { CycleProfileInput } from "@/lib/cycle/types";
|
||||
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { icsContentHash, type IcsCalendar } from "../ics";
|
||||
import { buildFeedCalendar, type FeedTitleMode } from "./events";
|
||||
import { historyPeriods, historyWindowStart } from "./history";
|
||||
|
||||
/** The feed options the body depends on. */
|
||||
export interface FeedOptions {
|
||||
id: string;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
/** The record the feed serves, as far as building it needs. */
|
||||
export interface FeedSubject {
|
||||
id: string;
|
||||
timezone: string | null;
|
||||
locale: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltFeed {
|
||||
calendar: IcsCalendar;
|
||||
/** SHA-256 of the body without its stamps. Doubles as the ETag. */
|
||||
contentHash: string;
|
||||
projectionState: ProjectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language the feed's descriptive titles use: the record's stored one, or
|
||||
* the instance default. Deliberately not `resolveServerLocale`, which would
|
||||
* fall back to the request's cookies and Accept-Language — the provider's,
|
||||
* not the person's.
|
||||
*/
|
||||
export function feedLocale(stored: string | null): Locale {
|
||||
return isLocale(stored) ? stored : defaultLocale;
|
||||
}
|
||||
|
||||
/** The engine's defaults, for a record whose profile row was never created. */
|
||||
const DEFAULT_PROFILE: CycleProfileInput = {
|
||||
goal: "GENERAL_HEALTH",
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
};
|
||||
|
||||
export async function buildCycleFeed(
|
||||
feed: FeedOptions,
|
||||
subject: FeedSubject,
|
||||
now: Date = new Date(),
|
||||
): Promise<BuiltFeed> {
|
||||
const tz = subject.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(now, tz);
|
||||
|
||||
const [profileRow, inputs] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId: subject.id } }),
|
||||
loadCycleEngineInputs({
|
||||
userId: subject.id,
|
||||
today,
|
||||
tz,
|
||||
dayLogsFrom: defaultCalendarDayLogStart(today),
|
||||
}),
|
||||
]);
|
||||
const profile = profileRow ? toProfileInput(profileRow) : DEFAULT_PROFILE;
|
||||
|
||||
const prediction = gatedPrediction(profile, inputs, today);
|
||||
const projection = projectCycles({
|
||||
prediction,
|
||||
cycles: toCycleInputs(inputs.cycles),
|
||||
dayLogs: inputs.dayLogs,
|
||||
goal: profile.goal,
|
||||
today,
|
||||
horizon: feed.horizonCycles,
|
||||
});
|
||||
|
||||
// The fertile window: the next cycle's, for a conception goal that asked
|
||||
// for it, and read off the gated DTO rather than the raw result so the
|
||||
// cold-start suppression applies here exactly as it does on every other
|
||||
// surface. Never for AVOID_PREGNANCY: a calendar event cannot carry the
|
||||
// "not a contraceptive method" caveat, and a fertile window without it is
|
||||
// the one thing this feature must not hand out.
|
||||
let fertile: { start: string; end: string } | null = null;
|
||||
if (
|
||||
feed.includeFertile &&
|
||||
profile.goal === "TRYING_TO_CONCEIVE" &&
|
||||
prediction !== null &&
|
||||
projection.cycles.length > 0
|
||||
) {
|
||||
const dto = toCyclePredictionDTO(
|
||||
prediction,
|
||||
goalAllowsFertileWindow(profile.goal),
|
||||
"",
|
||||
);
|
||||
if (dto.fertileWindowStart && dto.fertileWindowEnd) {
|
||||
fertile = { start: dto.fertileWindowStart, end: dto.fertileWindowEnd };
|
||||
}
|
||||
}
|
||||
|
||||
let history: ReturnType<typeof historyPeriods> = [];
|
||||
const historyFrom = historyWindowStart(
|
||||
inputs.cycles,
|
||||
today,
|
||||
feed.historyCycles,
|
||||
);
|
||||
if (historyFrom !== null) {
|
||||
const flowDays = await loadFlowDays(subject.id, historyFrom);
|
||||
history = historyPeriods(
|
||||
inputs.cycles,
|
||||
flowDays,
|
||||
today,
|
||||
feed.historyCycles,
|
||||
);
|
||||
}
|
||||
|
||||
const calendar = buildFeedCalendar({
|
||||
feedId: feed.id,
|
||||
titleMode: feed.titleMode,
|
||||
projected: projection.cycles,
|
||||
history,
|
||||
fertile,
|
||||
translator: getServerTranslator(feedLocale(subject.locale)),
|
||||
});
|
||||
|
||||
return {
|
||||
calendar,
|
||||
contentHash: icsContentHash(calendar),
|
||||
projectionState: projection.state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The calendar feed's events, from the projection, the record's history and
|
||||
* (for a conception goal that asked for it) the next fertile window (#1350).
|
||||
*
|
||||
* Pure. What an event may carry is decided here and nowhere else, and it is
|
||||
* dates and a title: never a symptom, a note, a flow level, a temperature, a
|
||||
* test result, the intent fields or anything else the day log holds. The
|
||||
* content-leak guard (`cycle-feed-content-guard.test.ts`) seeds every one of
|
||||
* those and asserts the rendered body contains none of them.
|
||||
*
|
||||
* DISCREET (the default) titles every event with a dot and names the calendar
|
||||
* "HealthLog", so a glance at a shared screen or a lock-screen notification
|
||||
* says nothing. DESCRIPTIVE names the period, in the record's own language.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
|
||||
/** The discreet title: one dot, no word. */
|
||||
export const DISCREET_SUMMARY = "●";
|
||||
/** The discreet calendar name. */
|
||||
export const DISCREET_CALENDAR_NAME = "HealthLog";
|
||||
|
||||
/** One logged period, as far as the feed draws it. */
|
||||
export interface HistoryPeriod {
|
||||
cycleId: string;
|
||||
/** First bleeding day. */
|
||||
start: string;
|
||||
/** Last bleeding day, inclusive. */
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface FeedEventInput {
|
||||
feedId: string;
|
||||
titleMode: FeedTitleMode;
|
||||
projected: readonly ProjectedCycle[];
|
||||
history: readonly HistoryPeriod[];
|
||||
/** The next fertile window, already gated; null when not shown. */
|
||||
fertile: { start: string; end: string } | null;
|
||||
translator: ServerTranslator;
|
||||
}
|
||||
|
||||
/** "12 Nov" in the record's language, for the descriptive description. */
|
||||
function shortDate(day: string, translator: ServerTranslator): string {
|
||||
return new Intl.DateTimeFormat(resolveIntlLocale(translator.locale), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${day}T12:00:00Z`));
|
||||
}
|
||||
|
||||
/** "±3 days", pluralised in the record's language. */
|
||||
function bandText(days: number, translator: ServerTranslator): string {
|
||||
return translator.t(
|
||||
pluralKey("cycle.feed.ics.band", days, translator.locale),
|
||||
{ count: days },
|
||||
);
|
||||
}
|
||||
|
||||
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
|
||||
const { translator } = input;
|
||||
const band = dayDiff(p.bandEnd, p.start);
|
||||
const descriptive = input.titleMode === "DESCRIPTIVE";
|
||||
return {
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start through
|
||||
// the last bleeding day of the latest likely start. DTEND is exclusive.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
description: descriptive
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band: bandText(band, translator),
|
||||
})
|
||||
: bandText(band, translator),
|
||||
};
|
||||
}
|
||||
|
||||
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
|
||||
return {
|
||||
uid: eventUid(input.feedId, `obs:${h.cycleId}`),
|
||||
start: h.start,
|
||||
endExclusive: addDays(h.end, 1),
|
||||
summary:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.periodLogged")
|
||||
: DISCREET_SUMMARY,
|
||||
};
|
||||
}
|
||||
|
||||
function fertileEvent(
|
||||
window: { start: string; end: string },
|
||||
input: FeedEventInput,
|
||||
): IcsEvent {
|
||||
return {
|
||||
uid: eventUid(input.feedId, "fertile:1"),
|
||||
start: window.start,
|
||||
endExclusive: addDays(window.end, 1),
|
||||
summary:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.fertileEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole calendar the feed serves, events in date order. */
|
||||
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
|
||||
const events: IcsEvent[] = [
|
||||
...input.history.map((h) => historyEvent(h, input)),
|
||||
...input.projected.map((p) => forecastEvent(p, input)),
|
||||
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
|
||||
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
|
||||
return {
|
||||
name:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.calendarName")
|
||||
: DISCREET_CALENDAR_NAME,
|
||||
events,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Which calendar app last fetched a feed, from its User-Agent (#1350).
|
||||
*
|
||||
* Only a family is ever published: the settings card says "by Google
|
||||
* Calendar", and the wide event counts conditional fetches per family. The
|
||||
* stored User-Agent itself stays on the row, truncated, and never leaves the
|
||||
* server.
|
||||
*/
|
||||
export type FetcherFamily = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
const FAMILIES: ReadonlyArray<[FetcherFamily, RegExp]> = [
|
||||
["GOOGLE", /google/i],
|
||||
// iOS and macOS fetch through `dataaccessd` / `CalendarAgent`; older macOS
|
||||
// still says `iCal`.
|
||||
["APPLE", /dataaccessd|calendaragent|ical\/|\bios\/|macos\/|mac os x/i],
|
||||
["MICROSOFT", /microsoft|outlook|exchange/i],
|
||||
];
|
||||
|
||||
export function fetcherFamily(ua: string | null): FetcherFamily | null {
|
||||
if (!ua) return null;
|
||||
for (const [family, re] of FAMILIES) if (re.test(ua)) return family;
|
||||
return "OTHER";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The logged periods the calendar feed may include as history (#1350).
|
||||
*
|
||||
* Pure. A period runs from the cycle's start to its `periodEndDate` when one
|
||||
* is recorded, otherwise across its observed bleeding days (the engine's own
|
||||
* §2 run rule), otherwise it is the start day alone. It never runs past today
|
||||
* or into the next cycle.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { observedPeriodLength } from "@/lib/cycle/prediction";
|
||||
import type { DayLogInput, FlowLevel } from "@/lib/cycle/types";
|
||||
|
||||
import type { HistoryPeriod } from "./events";
|
||||
|
||||
export interface HistoryCycleRow {
|
||||
id: string;
|
||||
startDate: string;
|
||||
periodEndDate: string | null;
|
||||
}
|
||||
|
||||
/** The earliest start of the last `count` cycles at or before today. */
|
||||
export function historyWindowStart(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
today: string,
|
||||
count: number,
|
||||
): string | null {
|
||||
return recent(cycles, today, count)[0]?.startDate ?? null;
|
||||
}
|
||||
|
||||
function recent(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
today: string,
|
||||
count: number,
|
||||
): HistoryCycleRow[] {
|
||||
if (count <= 0) return [];
|
||||
return [...cycles]
|
||||
.filter((c) => dayDiff(today, c.startDate) >= 0)
|
||||
.sort((a, b) => dayDiff(a.startDate, b.startDate))
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
export function historyPeriods(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
flowDays: ReadonlyArray<{ date: string; flow: FlowLevel | null }>,
|
||||
today: string,
|
||||
count: number,
|
||||
): HistoryPeriod[] {
|
||||
const chosen = recent(cycles, today, count);
|
||||
const sortedStarts = [...cycles]
|
||||
.map((c) => c.startDate)
|
||||
.sort((a, b) => dayDiff(a, b));
|
||||
const logs: DayLogInput[] = flowDays.map((d) => ({
|
||||
date: d.date,
|
||||
flow: d.flow,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
}));
|
||||
|
||||
return chosen.map((c) => {
|
||||
let end = c.startDate;
|
||||
if (c.periodEndDate && dayDiff(c.periodEndDate, c.startDate) >= 0) {
|
||||
end = c.periodEndDate;
|
||||
} else {
|
||||
const observed = observedPeriodLength(c.startDate, logs);
|
||||
if (observed > 1) end = addDays(c.startDate, observed - 1);
|
||||
}
|
||||
const nextStart = sortedStarts.find((s) => dayDiff(s, c.startDate) > 0);
|
||||
if (nextStart && dayDiff(end, nextStart) >= 0) end = addDays(nextStart, -1);
|
||||
if (dayDiff(end, today) > 0) end = today;
|
||||
return { cycleId: c.id, start: c.startDate, end };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Pieces the calendar-feed management routes share (#1350).
|
||||
*
|
||||
* Management is cookie-only. Minting hands out a standing URL credential, so
|
||||
* it needs the person at the keyboard (a fresh proof on the session) and is
|
||||
* not reachable from a Bearer token at all; the native app shows "set up on
|
||||
* the web" instead. The routes resolve through `requireRecordAuth("manage",
|
||||
* "cycle")`, so the owner and a MANAGE delegate over the cycle section reach
|
||||
* them and a READ or WRITE delegate does not.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
import { feedPath } from "./token";
|
||||
|
||||
/** Mint and rotate, per acting account. */
|
||||
export const FEED_MINT_LIMIT_PER_HOUR = 10;
|
||||
export const FEED_MINT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The refusal a Bearer caller gets: management is a web surface. */
|
||||
export function cookieOnlyRefusal(): Response {
|
||||
return apiError("Calendar subscriptions are managed on the web", 403, {
|
||||
errorCode: "cycle.feed.cookie_only",
|
||||
});
|
||||
}
|
||||
|
||||
export function noLiveFeed(): Response {
|
||||
return apiError("No calendar subscription is on", 404, {
|
||||
errorCode: "cycle.feed.none",
|
||||
});
|
||||
}
|
||||
|
||||
export function feedExists(): Response {
|
||||
return apiError(
|
||||
"A calendar subscription is already on. Rotate it for a new link.",
|
||||
409,
|
||||
{ errorCode: "cycle.feed.exists" },
|
||||
);
|
||||
}
|
||||
|
||||
/** The two forms of the link, built once, at mint. */
|
||||
export function feedLinks(
|
||||
origin: string,
|
||||
raw: string,
|
||||
): { url: string; webcalUrl: string } {
|
||||
const url = `${origin}${feedPath(raw)}`;
|
||||
return { url, webcalUrl: url.replace(/^https?:/, "webcal:") };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* `GET /api/cycle/feed/<hlf_token>` — the anonymous calendar-feed fetch
|
||||
* (#1350).
|
||||
*
|
||||
* The token in the path is the whole credential. A calendar provider polls it
|
||||
* from its own servers with no cookie and no header we could ask for, which is
|
||||
* why it is a URL credential at all. Everything it may do is decided here, on
|
||||
* every fetch, in this order:
|
||||
*
|
||||
* 1. The anonymous auth-surface limit, before anything is looked up, so a
|
||||
* prober walking token space pays for every guess (the webhook rule:
|
||||
* limit before verification).
|
||||
* 2. The token's shape. Anything that is not `hlf_` plus 48 hex is not
|
||||
* looked up at all.
|
||||
* 3. The HMAC lookup of a live (unrevoked) feed.
|
||||
* 4. The token's own bucket, so one leaked link cannot grind the engine.
|
||||
* 5. The gates, all read live and none of them through a session: the
|
||||
* record and the carrier are not pending deletion, the operator's
|
||||
* external-access switch is on, cycle tracking is available for the
|
||||
* record (user toggle AND operator module switch), and a feed minted by
|
||||
* a delegate still has a live MANAGE grant that covers the cycle section.
|
||||
*
|
||||
* Every miss is the same 404 with the same bytes: unknown, malformed,
|
||||
* revoked, switched off, deleted, or a grant that has lapsed. A prober learns
|
||||
* nothing from the difference because there is none.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { activeAccount } from "@/lib/account-deletion/active-account";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import {
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
grantCoversDomain,
|
||||
} from "@/lib/sharing/grant-access";
|
||||
|
||||
import { renderIcs } from "../ics";
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily } from "./fetcher";
|
||||
import { parseFeedTokenSegment } from "./token";
|
||||
|
||||
/**
|
||||
* Per source address, across every feed. Generous because the big calendar
|
||||
* providers poll from a pool of addresses that churns; the per-token bucket
|
||||
* below is the one that bounds a single link.
|
||||
*/
|
||||
const SURFACE_LIMIT_PER_HOUR = 120;
|
||||
/** Per link. A provider polling every few minutes still fits. */
|
||||
const TOKEN_LIMIT_PER_HOUR = 30;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The longest User-Agent kept for the status read. */
|
||||
const UA_MAX_LENGTH = 80;
|
||||
|
||||
/** Headers every answer carries, the 404 included. */
|
||||
function baseHeaders(): Record<string, string> {
|
||||
return {
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
};
|
||||
}
|
||||
|
||||
/** One blunt 404 for every miss class. */
|
||||
function notFound(reason: string): Response {
|
||||
annotate({ meta: { feed_miss: reason } });
|
||||
const response = apiError("Not found", 404, {
|
||||
errorCode: "cycle.feed.notFound",
|
||||
});
|
||||
for (const [k, v] of Object.entries(baseHeaders()))
|
||||
response.headers.set(k, v);
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
return response;
|
||||
}
|
||||
|
||||
function tooMany(snapshot: Parameters<typeof rateLimitHeaders>[0]): Response {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "cycle.feed.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries({
|
||||
...baseHeaders(),
|
||||
...rateLimitHeaders(snapshot),
|
||||
})) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const FEED_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
createdById: true,
|
||||
bodyHash: true,
|
||||
contentChangedAt: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Whether a feed may still be served. Every condition is read on this fetch;
|
||||
* nothing is cached on the row, so a revocation of anything the feed rests on
|
||||
* lands on the provider's next poll.
|
||||
*/
|
||||
async function feedStillAllowed(feed: {
|
||||
userId: string;
|
||||
createdById: string;
|
||||
}): Promise<
|
||||
{ ok: true; subject: FeedSubject } | { ok: false; reason: string }
|
||||
> {
|
||||
const [subject, apiOn, cycleOn] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: activeAccount({ id: feed.userId }),
|
||||
select: { id: true, timezone: true, locale: true },
|
||||
}),
|
||||
isApiGloballyEnabled(),
|
||||
isCycleAvailableForUser(feed.userId),
|
||||
]);
|
||||
if (!subject) return { ok: false, reason: "subject" };
|
||||
if (!apiOn) return { ok: false, reason: "api_disabled" };
|
||||
if (!cycleOn) return { ok: false, reason: "cycle_disabled" };
|
||||
|
||||
if (feed.createdById !== feed.userId) {
|
||||
const [carrier, grant] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: activeAccount({ id: feed.createdById }),
|
||||
select: { id: true },
|
||||
}),
|
||||
findActiveGrant({ grantorId: feed.userId, granteeId: feed.createdById }),
|
||||
]);
|
||||
if (!carrier) return { ok: false, reason: "carrier" };
|
||||
if (!grant || !grantAllows(grant, "manage")) {
|
||||
return { ok: false, reason: "grant" };
|
||||
}
|
||||
if (!grantCoversDomain(grant, "cycle"))
|
||||
return { ok: false, reason: "grant" };
|
||||
}
|
||||
return { ok: true, subject };
|
||||
}
|
||||
|
||||
/** Does the request's `If-None-Match` name this ETag? */
|
||||
function matchesEtag(request: NextRequest, etag: string): boolean {
|
||||
const header = request.headers.get("if-none-match");
|
||||
if (!header) return false;
|
||||
return header
|
||||
.split(",")
|
||||
.map((v) => v.trim().replace(/^W\//, ""))
|
||||
.some((v) => v === etag || v === "*");
|
||||
}
|
||||
|
||||
export async function serveCycleFeed(
|
||||
request: NextRequest,
|
||||
segment: string,
|
||||
): Promise<Response> {
|
||||
annotate({ action: { name: "cycle.feed.fetch" } });
|
||||
|
||||
// 1. Before any lookup.
|
||||
const surface = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"cycle-feed",
|
||||
SURFACE_LIMIT_PER_HOUR,
|
||||
HOUR_MS,
|
||||
);
|
||||
if (!surface.allowed) return tooMany(surface);
|
||||
|
||||
// 2. Shape.
|
||||
const token = parseFeedTokenSegment(segment);
|
||||
if (token === null) return notFound("malformed");
|
||||
|
||||
// 3. Lookup of a live feed.
|
||||
const tokenHash = hashToken(token);
|
||||
const feed = await prisma.cycleCalendarFeed.findFirst({
|
||||
where: { tokenHash, revokedAt: null },
|
||||
select: FEED_SELECT,
|
||||
});
|
||||
if (!feed) return notFound("unknown");
|
||||
|
||||
// 4. The link's own bucket.
|
||||
const perToken = await checkRateLimit(
|
||||
`cycle-feed:${tokenHash}`,
|
||||
TOKEN_LIMIT_PER_HOUR,
|
||||
HOUR_MS,
|
||||
);
|
||||
if (!perToken.allowed) return tooMany(perToken);
|
||||
|
||||
// 5. Gates.
|
||||
const allowed = await feedStillAllowed(feed);
|
||||
if (!allowed.ok) return notFound(allowed.reason);
|
||||
|
||||
// 6. The body, built live.
|
||||
const now = new Date();
|
||||
const built = await buildCycleFeed(feed, allowed.subject, now);
|
||||
const changed = built.contentHash !== feed.bodyHash;
|
||||
const changedAt = changed ? now : feed.contentChangedAt;
|
||||
const etag = `"${built.contentHash}"`;
|
||||
const conditional = request.headers.has("if-none-match");
|
||||
const notModified = !changed && matchesEtag(request, etag);
|
||||
|
||||
const ua = request.headers.get("user-agent")?.slice(0, UA_MAX_LENGTH) ?? null;
|
||||
trackBackgroundTask(
|
||||
runDetached(() =>
|
||||
prisma.cycleCalendarFeed
|
||||
.update({
|
||||
where: { id: feed.id },
|
||||
data: {
|
||||
lastFetchedAt: now,
|
||||
fetchCount: { increment: 1 },
|
||||
// A fetch without a User-Agent keeps the last one we know.
|
||||
...(ua ? { lastFetcherUa: ua } : {}),
|
||||
...(changed
|
||||
? { bodyHash: built.contentHash, contentChangedAt: changedAt }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// The statistics are best-effort; a failed write never fails the
|
||||
// fetch, and the next fetch writes them again.
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
status: notModified ? 304 : 200,
|
||||
conditional,
|
||||
uaFamily: fetcherFamily(ua),
|
||||
projection_state: built.projectionState,
|
||||
events: built.calendar.events.length,
|
||||
},
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...baseHeaders(),
|
||||
// `no-cache`, not `no-store`: a client keeps the body and its validator
|
||||
// and asks again with `If-None-Match`, which is what the 304 path is for.
|
||||
"Cache-Control": "private, no-cache, max-age=0",
|
||||
ETag: etag,
|
||||
};
|
||||
if (notModified) return new Response(null, { status: 304, headers });
|
||||
return new Response(renderIcs(built.calendar, changedAt), {
|
||||
status: 200,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'inline; filename="healthlog.ics"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* What the settings card may know about a record's calendar feed (#1350).
|
||||
*
|
||||
* The token's prefix, the options and the fetch statistics. Never the hash
|
||||
* and never the URL: the URL is returned once, by the mint, and after that it
|
||||
* exists only where the person pasted it.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ProjectionState } from "@/lib/cycle/projection";
|
||||
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily, type FetcherFamily } from "./fetcher";
|
||||
import type { FeedTitleMode } from "./events";
|
||||
|
||||
export const FEED_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
tokenPrefix: true,
|
||||
createdAt: true,
|
||||
lastFetchedAt: true,
|
||||
fetchCount: true,
|
||||
lastFetcherUa: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
} as const;
|
||||
|
||||
export interface FeedSummaryRow {
|
||||
id: string;
|
||||
tokenPrefix: string;
|
||||
createdAt: Date;
|
||||
lastFetchedAt: Date | null;
|
||||
fetchCount: number;
|
||||
lastFetcherUa: string | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
/** Which calendar app fetched last, as a family. Never the raw UA. */
|
||||
lastFetcher: FetcherFamily | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
/** The live feed, or null when the subscription is off. */
|
||||
feed: CycleFeedSummary | null;
|
||||
/**
|
||||
* What the live feed serves right now (OK / LEARNING / LATE / OFF), so the
|
||||
* card can say why it holds fewer cycles than asked. Null with no feed.
|
||||
*/
|
||||
projectionState: ProjectionState | null;
|
||||
/** Whether the record's goal admits the fertile-window option at all. */
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
|
||||
return {
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
lastFetchedAt: row.lastFetchedAt?.toISOString() ?? null,
|
||||
fetchCount: row.fetchCount,
|
||||
lastFetcher: fetcherFamily(row.lastFetcherUa),
|
||||
horizonCycles: row.horizonCycles,
|
||||
historyCycles: row.historyCycles,
|
||||
includeFertile: row.includeFertile,
|
||||
titleMode: row.titleMode,
|
||||
};
|
||||
}
|
||||
|
||||
/** The record's live feed, or null. The partial index admits at most one. */
|
||||
export function findLiveFeed(userId: string): Promise<FeedSummaryRow | null> {
|
||||
return prisma.cycleCalendarFeed.findFirst({
|
||||
where: { userId, revokedAt: null },
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cycleFeedStatus(
|
||||
subject: FeedSubject,
|
||||
goal: string,
|
||||
): Promise<CycleFeedStatus> {
|
||||
const live = await findLiveFeed(subject.id);
|
||||
const fertileAvailable = goal === "TRYING_TO_CONCEIVE";
|
||||
if (!live) return { feed: null, projectionState: null, fertileAvailable };
|
||||
const built = await buildCycleFeed(live, subject);
|
||||
return {
|
||||
feed: toFeedSummary(live),
|
||||
projectionState: built.projectionState,
|
||||
fertileAvailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The `hlf_` calendar-feed credential (#1350).
|
||||
*
|
||||
* `hlf_` plus 48 hex characters: 192 random bits, the share link's strength.
|
||||
* Only `hashToken(raw)` is stored, under the same `API_TOKEN_HMAC_KEY` as every
|
||||
* other HealthLog credential, so rotating that key ends every feed URL along
|
||||
* with every token (documented in the self-hosting guide). The raw token is
|
||||
* returned once at mint and cannot be recovered afterwards.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
export const FEED_TOKEN_PREFIX = "hlf_";
|
||||
|
||||
/** The exact shape a feed token has. Anything else is not looked up. */
|
||||
const FEED_TOKEN_SHAPE = /^hlf_[0-9a-f]{48}$/;
|
||||
|
||||
/** How many leading characters the settings card shows. */
|
||||
export const FEED_TOKEN_DISPLAY_PREFIX_LENGTH = 8;
|
||||
|
||||
export interface MintedFeedToken {
|
||||
raw: string;
|
||||
hash: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function mintFeedToken(): MintedFeedToken {
|
||||
const raw = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
|
||||
return {
|
||||
raw,
|
||||
hash: hashToken(raw),
|
||||
prefix: raw.slice(0, FEED_TOKEN_DISPLAY_PREFIX_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The token from a path segment, or null when it is not one. A trailing
|
||||
* `.ics` is accepted and stripped, because calendar apps are happier with a
|
||||
* URL that names its file type.
|
||||
*/
|
||||
export function parseFeedTokenSegment(segment: string): string | null {
|
||||
const token = segment.endsWith(".ics") ? segment.slice(0, -4) : segment;
|
||||
return FEED_TOKEN_SHAPE.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** The feed path for a raw token, relative to the instance origin. */
|
||||
export function feedPath(raw: string): string {
|
||||
return `/api/cycle/feed/${raw}.ics`;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* A small, hand-rolled RFC 5545 writer for the cycle calendar feed (#1350).
|
||||
*
|
||||
* The feed needs one shape of calendar: all-day events, a published
|
||||
* subscription, no time zones and no recurrence. That is a few dozen lines of
|
||||
* the specification, so it is written here rather than taken from a
|
||||
* dependency whose defaults (descriptions, organisers, alarms, a VTIMEZONE)
|
||||
* would each be something to switch off.
|
||||
*
|
||||
* What the specification asks for and this does:
|
||||
* * CRLF line endings (§3.1).
|
||||
* * Lines folded at 75 octets, never inside a UTF-8 sequence, continued with
|
||||
* a single space (§3.1).
|
||||
* * TEXT escaping of backslash, semicolon, comma and newline (§3.3.11).
|
||||
* * All-day events as `VALUE=DATE`, with DTEND exclusive (§3.6.1).
|
||||
*
|
||||
* Pure: no clock, no database. The caller hands in the stamp.
|
||||
*/
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
export interface IcsEvent {
|
||||
/** Globally unique and stable across rebuilds of the same feed. */
|
||||
uid: string;
|
||||
/** First day, `YYYY-MM-DD`. */
|
||||
start: string;
|
||||
/** The day AFTER the last day, `YYYY-MM-DD` (DTEND is exclusive). */
|
||||
endExclusive: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IcsCalendar {
|
||||
/** `X-WR-CALNAME`, the name a calendar app shows for the subscription. */
|
||||
name: string;
|
||||
events: readonly IcsEvent[];
|
||||
}
|
||||
|
||||
const CRLF = "\r\n";
|
||||
const MAX_OCTETS = 75;
|
||||
|
||||
/** The stamp a body is hashed with, so the hash never depends on the clock. */
|
||||
const HASH_STAMP = "19700101T000000Z";
|
||||
|
||||
/** §3.3.11 — escape a TEXT value. */
|
||||
export function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\r\n|\r|\n/g, "\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.1 — fold a content line at 75 octets. Continuation lines begin with one
|
||||
* space, which counts toward their own 75. Splits only between code points,
|
||||
* so a multibyte character is never cut in half.
|
||||
*/
|
||||
export function foldLine(line: string): string {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let octets = 0;
|
||||
for (const ch of line) {
|
||||
const size = Buffer.byteLength(ch, "utf8");
|
||||
if (octets + size > MAX_OCTETS) {
|
||||
out.push(current);
|
||||
current = " ";
|
||||
octets = 1;
|
||||
}
|
||||
current += ch;
|
||||
octets += size;
|
||||
}
|
||||
out.push(current);
|
||||
return out.join(CRLF);
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → `YYYYMMDD`. */
|
||||
function icsDate(day: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||||
throw new Error(`Not a calendar day: ${day}`);
|
||||
}
|
||||
return day.replace(/-/g, "");
|
||||
}
|
||||
|
||||
/** A UTC instant as `YYYYMMDDTHHMMSSZ`. */
|
||||
export function icsStamp(at: Date): string {
|
||||
return at
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z")
|
||||
.replace(/[-:]/g, "");
|
||||
}
|
||||
|
||||
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
/** RFC 4648 base32, lower case, unpadded. */
|
||||
function base32(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable event UID: the feed's id keys an HMAC over the slot (`obs:<cycle
|
||||
* id>` or `pred:<k>`). Stable across rebuilds of one feed, so a calendar app
|
||||
* updates an event in place; unrelated across feeds, so a rotated link starts
|
||||
* a fresh set; and it carries neither a user id nor a host.
|
||||
*/
|
||||
export function eventUid(feedId: string, slot: string): string {
|
||||
const mac = createHmac("sha256", feedId).update(slot).digest();
|
||||
return `${base32(mac.subarray(0, 20))}@healthlog.invalid`;
|
||||
}
|
||||
|
||||
function render(calendar: IcsCalendar, stamp: string): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//HealthLog//Calendar feed//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${escapeText(calendar.name)}`,
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
||||
"X-PUBLISHED-TTL:PT6H",
|
||||
];
|
||||
for (const event of calendar.events) {
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${event.uid}`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`LAST-MODIFIED:${stamp}`,
|
||||
`DTSTART;VALUE=DATE:${icsDate(event.start)}`,
|
||||
`DTEND;VALUE=DATE:${icsDate(event.endExclusive)}`,
|
||||
`SUMMARY:${escapeText(event.summary)}`,
|
||||
);
|
||||
if (event.description) {
|
||||
lines.push(`DESCRIPTION:${escapeText(event.description)}`);
|
||||
}
|
||||
lines.push("TRANSP:TRANSPARENT", "CLASS:PRIVATE", "END:VEVENT");
|
||||
}
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.map(foldLine).join(CRLF) + CRLF;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHA-256 of the calendar as it would render with a fixed stamp. Equal
|
||||
* inputs give an equal hash whatever the clock says, which is what lets the
|
||||
* feed move its stamps only when the content moved.
|
||||
*/
|
||||
export function icsContentHash(calendar: IcsCalendar): string {
|
||||
return createHash("sha256")
|
||||
.update(render(calendar, HASH_STAMP))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** The calendar, every event stamped with `changedAt`. */
|
||||
export function renderIcs(calendar: IcsCalendar, changedAt: Date): string {
|
||||
return render(calendar, icsStamp(changedAt));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The absolute origin a link the app hands out is built from.
|
||||
*
|
||||
* Prefers the forwarded host and protocol a reverse proxy sets (the documented
|
||||
* self-hosting path fronts the app with Caddy, Traefik or nginx), falling back
|
||||
* to the request URL's own origin. It trusts `X-Forwarded-Host` exactly as far
|
||||
* as the share links always have: the value only ever reaches the caller who
|
||||
* sent it, in the response to their own authenticated request.
|
||||
*
|
||||
* Shared by the clinician share links and the cycle calendar feed, so the two
|
||||
* links a person copies out of the app always name the same host.
|
||||
*/
|
||||
export function resolveOrigin(request: Request): string {
|
||||
const proto =
|
||||
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
|
||||
new URL(request.url).protocol.replace(":", "");
|
||||
const host =
|
||||
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("host") ||
|
||||
new URL(request.url).host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Edge headers for the paths whose credential is the URL itself.
|
||||
*
|
||||
* The public clinician share view at `/c/<token>` is a scoped
|
||||
* health record authenticated by an unguessable bearer token in the path.
|
||||
* Defend it at the edge regardless of what the RSC emits:
|
||||
* - `Cache-Control: no-store` so no shared proxy / CDN ever retains a
|
||||
* scoped record (the page is `force-dynamic`, but that governs Next's
|
||||
* cache, not a downstream intermediary).
|
||||
* - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's
|
||||
* `robots` meta — a crawler that never parses the document still obeys
|
||||
* the header, and the token must never reach a search index.
|
||||
* - `Referrer-Policy: no-referrer` so the token-bearing URL is not leaked
|
||||
* in the `Referer` of any outbound navigation from the page.
|
||||
*
|
||||
* The invite landing `/invite/<hlv_token>` (iOS #16) carries an equally sensitive
|
||||
* secret in the path, so it earns the same edge defence even though it is a
|
||||
* pure server redirect: no shared proxy / CDN may cache it and no crawler may
|
||||
* index the token-bearing URL.
|
||||
*
|
||||
* The cycle calendar feed (`/api/cycle/feed/<hlf_token>`) carries its
|
||||
* credential in the path too. It is kept out of indexes and referrers like the
|
||||
* share view, but its caching is the route's own: `private, no-cache` plus an
|
||||
* ETag, so a provider can ask again with `If-None-Match` instead of
|
||||
* downloading the body on every poll. `no-store` here would throw the
|
||||
* validator away.
|
||||
*
|
||||
* Runs after the proxy's general header block, so these values win.
|
||||
*/
|
||||
export function setPathCredentialHeaders(
|
||||
pathname: string,
|
||||
headers: Headers,
|
||||
): void {
|
||||
const feed = pathname.startsWith("/api/cycle/feed/");
|
||||
const page = pathname.startsWith("/c/") || pathname.startsWith("/invite/");
|
||||
if (!feed && !page) return;
|
||||
if (page) headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
@@ -127,6 +127,7 @@ describe("redactSecrets", () => {
|
||||
it.each([
|
||||
["an invite token", `hlv_${"0a".repeat(32)}`],
|
||||
["a managed-profile handover token", `hlp_${"0a".repeat(32)}`],
|
||||
["a cycle calendar-feed token", `hlf_${"0a".repeat(24)}`],
|
||||
["a session secret", `hls_${"ab".repeat(32)}`],
|
||||
["an elevation", `hle_${"cd".repeat(32)}`],
|
||||
["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`],
|
||||
@@ -332,6 +333,36 @@ describe("notification quick-answer token in the path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle calendar-feed token in the path (#1350)", () => {
|
||||
const token = `hlf_${"5b".repeat(24)}`;
|
||||
|
||||
it("scrubs the feed token from the path, with or without .ics", () => {
|
||||
expect(redactSecrets(`/api/cycle/feed/${token}`)).toBe(
|
||||
"/api/cycle/feed/[REDACTED]",
|
||||
);
|
||||
expect(redactSecrets(`/api/cycle/feed/${token}.ics`)).toBe(
|
||||
"/api/cycle/feed/[REDACTED]",
|
||||
);
|
||||
expect(
|
||||
redactSecrets(`GET https://health.example/api/cycle/feed/${token}.ics`),
|
||||
).toBe("GET https://health.example/api/cycle/feed/[REDACTED]");
|
||||
expect(PATH_SECRET_PATHS.map((p) => p.prefix)).toContain(
|
||||
"/api/cycle/feed/",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the management namespace readable", () => {
|
||||
// A different segment, so the feed prefix (ending in `/`) never reaches
|
||||
// it: an operator reading the logs still sees which management verb ran.
|
||||
expect(redactSecrets("/api/cycle/feed-subscription")).toBe(
|
||||
"/api/cycle/feed-subscription",
|
||||
);
|
||||
expect(redactSecrets("/api/cycle/feed-subscription/rotate")).toBe(
|
||||
"/api/cycle/feed-subscription/rotate",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Prisma prints a failed call, arguments and all, into its error message and
|
||||
* stack. Those went to the logs and to the operator's GlitchTip with a
|
||||
|
||||
@@ -72,6 +72,12 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{
|
||||
// single-use action token is the request's only credential and rides the
|
||||
// trailing path segment of `/api/notifications/action/<token>`.
|
||||
{ prefix: "/api/notifications/action/" },
|
||||
// #1350 — the cycle calendar feed. A calendar provider polls
|
||||
// `/api/cycle/feed/<hlf_token>.ics` with no session; the path segment is
|
||||
// the whole credential. The management routes live under
|
||||
// `/api/cycle/feed-subscription`, a different segment, so this prefix
|
||||
// (which ends in `/`) never matches them.
|
||||
{ prefix: "/api/cycle/feed/" },
|
||||
];
|
||||
|
||||
function redactPathSegments(input: string): string {
|
||||
@@ -140,12 +146,13 @@ export function redactSecrets(input: string): string {
|
||||
// HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh
|
||||
// token, `hls_` session cookie secret (v1.30.32) and clinician share
|
||||
// token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite,
|
||||
// `hlp_` managed-profile handover link (v1.42).
|
||||
// `hlp_` managed-profile handover link (v1.42), `hlf_` cycle
|
||||
// calendar-feed link (#1350).
|
||||
// Same boundary-aware shape as the `sk-` rule. The
|
||||
// idempotency replay-cache already rejects bodies containing
|
||||
// these prefixes (CLAUDE.md headless-client-API patterns); this
|
||||
// is the matching egress guard for log/error surfaces.
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresvf]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
|
||||
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
|
||||
// are signed, base64url-bodied, and credential-bearing, so they are
|
||||
|
||||
@@ -144,6 +144,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"cycle.day-log.conflict",
|
||||
"cycle.day-log.invalid",
|
||||
"cycle.disabled",
|
||||
"cycle.feed.api_disabled",
|
||||
"cycle.feed.cookie_only",
|
||||
"cycle.feed.exists",
|
||||
"cycle.feed.fertile_goal",
|
||||
"cycle.feed.none",
|
||||
"cycle.feed.notFound",
|
||||
"cycle.feed.rateLimited",
|
||||
"cycle.period.invalid",
|
||||
"cycle.symptom.custom.invalid",
|
||||
"cycle.symptom.custom.limit",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* OpenAPI route table — the subscribable cycle forecast (#1350): the
|
||||
* anonymous `.ics` feed, its cookie-only management routes, and the
|
||||
* projection the calendar read publishes beside its forecast.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* schema comes from `src/lib/validations/cycle-feed.ts`, so the wire contract
|
||||
* stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
cycleFeedOptionsPatchSchema,
|
||||
cycleFeedTitleModeEnum,
|
||||
} from "@/lib/validations/cycle-feed";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
malformedJsonResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const dayKey = () => z.string().meta({ format: "date", example: "2026-11-12" });
|
||||
|
||||
/** One projected period, as `/api/cycle/calendar` publishes it. */
|
||||
export const projectedCycleDto = z
|
||||
.object({
|
||||
k: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"1 = the next period (the engine's own forecast), 2 = the one after it, …",
|
||||
),
|
||||
start: dayKey().describe("The most likely first day."),
|
||||
periodLength: z.number().int().describe("Predicted bleeding days."),
|
||||
bandStart: dayKey().describe("The earliest likely first day."),
|
||||
bandEnd: dayKey().describe("The latest likely first day."),
|
||||
})
|
||||
.meta({
|
||||
id: "ProjectedCycleDTO",
|
||||
description:
|
||||
"A forecast period start with its band. The band widens from one cycle to the next and the list stops itself where it would pass ±10 days or half a cycle. Render it as given; never extrapolate further cycles on a client.",
|
||||
});
|
||||
|
||||
export const projectionStateEnum = z
|
||||
.enum(["OK", "LEARNING", "LATE", "OFF"])
|
||||
.meta({
|
||||
id: "CycleProjectionState",
|
||||
description:
|
||||
"Why `projection` holds what it holds. `OK`: the projected cycles. `LEARNING`: fewer than three observed cycles, so the next period only (or nothing with no logged cycle). `LATE`: the next period's band has passed with no new period logged; no forecast until one is. `OFF`: prediction is off or raw-chart mode is on.",
|
||||
});
|
||||
|
||||
const fetcherFamilyEnum = z.enum(["GOOGLE", "APPLE", "MICROSOFT", "OTHER"]);
|
||||
|
||||
const feedSummary = z
|
||||
.object({
|
||||
tokenPrefix: z
|
||||
.string()
|
||||
.describe(
|
||||
"The first 8 characters of the link's token, to tell links apart.",
|
||||
),
|
||||
createdAt: isoDateTime(),
|
||||
lastFetchedAt: isoDateTime().nullable(),
|
||||
fetchCount: z.number().int(),
|
||||
lastFetcher: fetcherFamilyEnum
|
||||
.nullable()
|
||||
.describe(
|
||||
"Which calendar app fetched last, as a family; never the raw User-Agent.",
|
||||
),
|
||||
horizonCycles: z.number().int().min(1).max(6),
|
||||
historyCycles: z.number().int().min(0).max(12),
|
||||
includeFertile: z.boolean(),
|
||||
titleMode: cycleFeedTitleModeEnum,
|
||||
})
|
||||
.meta({
|
||||
id: "CycleFeedSummary",
|
||||
description:
|
||||
"The live calendar subscription, without its secret: never the token hash and never the URL.",
|
||||
});
|
||||
|
||||
const feedStatus = z.object({
|
||||
feed: feedSummary.nullable(),
|
||||
projectionState: projectionStateEnum.nullable(),
|
||||
fertileAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only).",
|
||||
),
|
||||
});
|
||||
|
||||
const feedMinted = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
"The feed URL. Returned exactly once; it cannot be read back later.",
|
||||
),
|
||||
webcalUrl: z
|
||||
.string()
|
||||
.describe(
|
||||
"The same URL with the `webcal:` scheme, which opens a calendar app's subscribe dialog.",
|
||||
),
|
||||
feed: feedSummary,
|
||||
});
|
||||
|
||||
const feedOptionsRequest = cycleFeedOptionsPatchSchema.meta({
|
||||
id: "CycleFeedOptionsPatch",
|
||||
description:
|
||||
"The writable options. At least one field. `includeFertile: true` is refused (422 `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE.",
|
||||
});
|
||||
|
||||
const MANAGEMENT_REFUSALS = recordRefusal(
|
||||
"Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE delegate whose grant covers the cycle section, never by a READ or WRITE delegate.",
|
||||
);
|
||||
|
||||
const REPROOF_401 = {
|
||||
"401": {
|
||||
description:
|
||||
"Not signed in, or the session has no fresh proof: turning the subscription on or rotating it needs a sign-in or `POST /api/auth/reproof` within the last five minutes, answered otherwise with 401 `auth.reproof.required` and `meta.methods`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
const mintResponses = {
|
||||
"201": {
|
||||
description: "The new link, returned exactly once.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedMinted, "CycleFeedMinted"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
...REPROOF_401,
|
||||
};
|
||||
|
||||
export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/cycle/feed/{token}": {
|
||||
get: {
|
||||
tags: ["Cycle"],
|
||||
summary: "The subscribable cycle forecast as iCalendar (public, #1350)",
|
||||
description:
|
||||
"Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.",
|
||||
security: [],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe("Raw `hlf_` feed token, optionally with `.ics`."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The calendar. `Cache-Control: private, no-cache, max-age=0`, `ETag`.",
|
||||
content: {
|
||||
"text/calendar": { schema: z.string() },
|
||||
},
|
||||
},
|
||||
"304": {
|
||||
description: "The body is unchanged since the `If-None-Match` ETag.",
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Flat 404 for every miss class (errorCode `cycle.feed.notFound`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description: "The source-address or per-token bucket is exhausted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
headers: stdResponses["429"].headers,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/feed-subscription": {
|
||||
get: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Calendar subscription status (#1350)",
|
||||
description:
|
||||
"The record's live calendar subscription without its secret (prefix, options, fetch statistics) and what the feed serves right now. Never the hash, never the URL.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The status; `feed` is null while the subscription is off.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedStatus, "CycleFeedStatus"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Turn the calendar subscription on (#1350)",
|
||||
description:
|
||||
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"409": {
|
||||
description: "A subscription is already on (`cycle.feed.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Change the calendar subscription's options (#1350)",
|
||||
description:
|
||||
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: feedOptionsRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The status after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedStatus, "CycleFeedStatusAfterPatch"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...malformedJsonResponse,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Turn the calendar subscription off (#1350)",
|
||||
description:
|
||||
"Revokes the link; calendars holding it stop updating on their next poll. Copies a calendar provider already holds are beyond the app's reach.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Revoked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.literal(true) }),
|
||||
"CycleFeedRevoked",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/feed-subscription/rotate": {
|
||||
post: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Replace the calendar subscription's link (#1350)",
|
||||
description:
|
||||
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating.",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
listEnvelope,
|
||||
} from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { projectedCycleDto, projectionStateEnum } from "./cycle-feed";
|
||||
|
||||
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
|
||||
// The `/api/cycle` capture / calendar / history / settings surface +
|
||||
@@ -316,6 +317,12 @@ const cycleCalendarResponse = z.object({
|
||||
// population prior) — the client shows a calm "learning your cycle" state.
|
||||
// Additive + back-compatible.
|
||||
stillLearning: z.boolean(),
|
||||
// #1350 — the next cycles with widening bands, from the same engine run and
|
||||
// the same projection the calendar feed serves. Additive. Fertile fields are
|
||||
// never here; they stay in `prediction.*` behind the goal and cold-start
|
||||
// gates.
|
||||
projection: z.array(projectedCycleDto),
|
||||
projectionState: projectionStateEnum,
|
||||
days: z.array(cycleCalendarDayDto),
|
||||
meta: z.object({ generatedAt: isoDateTime() }),
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ import { consentPaths } from "./consent";
|
||||
import { customMetricPaths } from "./custom-metrics";
|
||||
import { dailyPaths } from "./daily";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { cycleFeedPaths } from "./cycle-feed";
|
||||
import { dashboardWidgetPaths } from "./dashboard";
|
||||
import { dayPaths } from "./day";
|
||||
import { searchPaths } from "./search";
|
||||
@@ -100,6 +101,7 @@ import { coachPrefsSchema, withScopeRefusal } from "./shared";
|
||||
|
||||
const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...cyclePaths,
|
||||
...cycleFeedPaths,
|
||||
...metaPaths,
|
||||
...healthRecordPaths,
|
||||
...exportPaths,
|
||||
|
||||
@@ -57,6 +57,15 @@ describe("isAllowlistedApiRead — offline read boundary", () => {
|
||||
expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("never caches the cycle calendar feed or its management routes (#1350)", () => {
|
||||
// The feed URL is a credential and its body is a record's cycle dates;
|
||||
// neither belongs in a browser's durable storage.
|
||||
const feed = `/api/cycle/feed/hlf_${"1f".repeat(24)}.ics`;
|
||||
expect(isAllowlistedApiRead(feed)).toBe(false);
|
||||
expect(isAllowlistedApiRead("/api/cycle/feed-subscription")).toBe(false);
|
||||
expect(SW_SOURCE).not.toContain("/api/cycle");
|
||||
});
|
||||
|
||||
it("does not match a prefix that is only a partial path segment", () => {
|
||||
// `/api/measurements-export` must NOT match `/api/measurements`.
|
||||
expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false);
|
||||
|
||||
@@ -34,4 +34,8 @@ export const cycleKeys = {
|
||||
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
|
||||
* merges into the seeded chip grid. */
|
||||
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
|
||||
/** #1350 — the calendar subscription's status. Under the `["cycle"]` prefix
|
||||
* on purpose: a logged period changes what the feed serves (a late period
|
||||
* becomes a forecast again), so every cycle write refreshes the card too. */
|
||||
cycleFeedSubscription: () => ["cycle", "feed-subscription"] as const,
|
||||
};
|
||||
@@ -65,6 +65,8 @@ export function recordActivityVerbLine(
|
||||
if (refused !== null) return refused;
|
||||
const medication = medicationManageVerb(t, action, name);
|
||||
if (medication !== null) return medication;
|
||||
const cycle = cycleManageVerb(t, action, name);
|
||||
if (cycle !== null) return cycle;
|
||||
switch (action) {
|
||||
case "measurement.create":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
@@ -270,19 +272,6 @@ export function recordActivityVerbLine(
|
||||
case "mental-health.create":
|
||||
return t("recordSharing.activityVerb.mentalHealthCreate", { name });
|
||||
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
|
||||
case "nutrient.water.write":
|
||||
return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
|
||||
|
||||
@@ -496,3 +485,39 @@ function medicationManageVerb(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle section's delegated acts: its log, its period boundaries, its
|
||||
* symptom vocabulary and its calendar subscription (#1350). Null for anything
|
||||
* else, so the caller falls through to the rest of the list.
|
||||
*/
|
||||
function cycleManageVerb(
|
||||
t: Translator,
|
||||
action: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
switch (action) {
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
case "cycle.feed.create":
|
||||
return t("recordSharing.activityVerb.cycleFeedCreate", { name });
|
||||
case "cycle.feed.update":
|
||||
return t("recordSharing.activityVerb.cycleFeedUpdate", { name });
|
||||
case "cycle.feed.rotate":
|
||||
return t("recordSharing.activityVerb.cycleFeedRotate", { name });
|
||||
case "cycle.feed.revoke":
|
||||
return t("recordSharing.activityVerb.cycleFeedRevoke", { name });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
|
||||
* tokens and session secrets, `hlv_` registration invite tokens, `hlp_`
|
||||
* managed-profile handover tokens (v1.42), `hle_`
|
||||
* managed-profile handover tokens (v1.42), `hlf_` cycle calendar-feed
|
||||
* tokens (#1350), `hle_`
|
||||
* elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth
|
||||
* artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_`
|
||||
* (client id);
|
||||
@@ -18,7 +19,7 @@
|
||||
* that knows the secrets in play must also refuse text containing them.
|
||||
*/
|
||||
const SECRET_SHAPED =
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hlf_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
|
||||
export function looksSecretShaped(text: string): boolean {
|
||||
return SECRET_SHAPED.test(text);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The cycle calendar feed's request contract (#1350).
|
||||
*
|
||||
* Only the options are writable. The token, the record and the carrier are
|
||||
* never body fields: the record comes from the resolved session, the carrier
|
||||
* is the actor, and the token is minted by the server.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */
|
||||
export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]);
|
||||
|
||||
/** Forecast cycles ahead. The feed caps itself further by band width. */
|
||||
export const FEED_HORIZON_MIN = 1;
|
||||
export const FEED_HORIZON_MAX = 6;
|
||||
/** Observed cycles included as history. */
|
||||
export const FEED_HISTORY_MIN = 0;
|
||||
export const FEED_HISTORY_MAX = 12;
|
||||
|
||||
export const cycleFeedOptionsPatchSchema = z
|
||||
.object({
|
||||
horizonCycles: z
|
||||
.number()
|
||||
.int()
|
||||
.min(FEED_HORIZON_MIN)
|
||||
.max(FEED_HORIZON_MAX)
|
||||
.optional(),
|
||||
historyCycles: z
|
||||
.number()
|
||||
.int()
|
||||
.min(FEED_HISTORY_MIN)
|
||||
.max(FEED_HISTORY_MAX)
|
||||
.optional(),
|
||||
includeFertile: z.boolean().optional(),
|
||||
titleMode: cycleFeedTitleModeEnum.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0, {
|
||||
message: "At least one option is required",
|
||||
});
|
||||
|
||||
export type CycleFeedOptionsPatch = z.infer<typeof cycleFeedOptionsPatchSchema>;
|
||||
+5
-24
@@ -7,6 +7,7 @@ import {
|
||||
RETIRED_ROUTE_STATUS,
|
||||
} from "@/lib/http/retired-routes";
|
||||
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
|
||||
import { setPathCredentialHeaders } from "@/lib/http/path-credential-headers";
|
||||
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
|
||||
import { resolveRequestId } from "@/lib/http/request-id";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
@@ -49,6 +50,8 @@ export const PUBLIC_PATHS = [
|
||||
// token in the path is the whole credential, verified by the route after
|
||||
// its rate limit. Trailing slash: admits only `/action/<token>`.
|
||||
"/api/notifications/action/",
|
||||
// #1350 — the calendar feed; the `hlf_` path token is the credential.
|
||||
"/api/cycle/feed/",
|
||||
"/api/monitoring/",
|
||||
"/api/withings/webhook",
|
||||
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
|
||||
@@ -691,30 +694,8 @@ export function proxy(request: NextRequest) {
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
|
||||
// v1.11.0 — the public clinician share view at `/c/<token>`
|
||||
// is a scoped health record authenticated by an unguessable bearer token
|
||||
// in the path. Defend it at the edge regardless of what the RSC emits:
|
||||
// - `Cache-Control: no-store` so no shared proxy / CDN ever retains a
|
||||
// scoped record (the page is `force-dynamic`, but that governs Next's
|
||||
// cache, not a downstream intermediary).
|
||||
// - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's
|
||||
// `robots` meta — a crawler that never parses the document still obeys
|
||||
// the header, and the token must never reach a search index.
|
||||
// - `Referrer-Policy: no-referrer` so the token-bearing URL is not
|
||||
// leaked in the `Referer` of any outbound navigation from the page.
|
||||
//
|
||||
// v1.17.0 — `/invite/<hlv_token>` (iOS #16) carries an equally sensitive
|
||||
// secret in the path, so it earns the same edge defence even though it is
|
||||
// a pure server redirect: no shared proxy / CDN may cache it and no
|
||||
// crawler may index the token-bearing URL.
|
||||
if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) {
|
||||
response.headers.set(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
response.headers.set("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
// Paths whose credential is the URL itself (share view, invite, feed).
|
||||
setPathCredentialHeaders(pathname, response.headers);
|
||||
// COOP isolates this BrowsingContextGroup from cross-origin popups,
|
||||
// closing the Spectre-class side-channel surface a stray
|
||||
// `window.opener` reference would otherwise carry. CORP narrows the
|
||||
|
||||
+32
@@ -754,6 +754,38 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/cycle/feed-subscription/route.ts",
|
||||
"/api/cycle/feed-subscription",
|
||||
"POST",
|
||||
"cycle",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/cycle/feed-subscription/route.ts",
|
||||
"/api/cycle/feed-subscription",
|
||||
"PATCH",
|
||||
"cycle",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/cycle/feed-subscription/route.ts",
|
||||
"/api/cycle/feed-subscription",
|
||||
"DELETE",
|
||||
"cycle",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/cycle/feed-subscription/rotate/route.ts",
|
||||
"/api/cycle/feed-subscription/rotate",
|
||||
"POST",
|
||||
"cycle",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/medications/categories/route.ts",
|
||||
"/api/medications/categories",
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* #1350 — the subscribable cycle forecast, against real Postgres.
|
||||
*
|
||||
* The management routes and the anonymous feed driven through their shipped
|
||||
* exports: the URL is returned once and never again, every miss class is the
|
||||
* same 404, a Bearer token and a stale session cannot mint, a READ delegate
|
||||
* cannot, the partial index holds one live feed per record, and the lifecycle
|
||||
* hooks (handover claim, carrier deletion, credential remediation) end the
|
||||
* link.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const db = () => getPrismaClient();
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string, extra: Record<string, unknown> = {}) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return db().user.create({
|
||||
data: {
|
||||
username: `cf-${suffix}`,
|
||||
email: `cf-${suffix}@example.test`,
|
||||
displayName: `CF ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
gender: "FEMALE",
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string, createdAt?: Date) {
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
const session = await db().session.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Thirteen regular cycles, the last one starting three weeks ago. */
|
||||
async function seedCycles(userId: string) {
|
||||
const today = new Date();
|
||||
const lastStart = new Date(today.getTime() - 21 * 86_400_000);
|
||||
for (let i = 12; i >= 0; i--) {
|
||||
const start = new Date(lastStart.getTime() - i * 28 * 86_400_000);
|
||||
const key = start.toISOString().slice(0, 10);
|
||||
await db().menstrualCycle.create({
|
||||
data: {
|
||||
userId,
|
||||
startDate: key,
|
||||
periodEndDate: new Date(start.getTime() + 4 * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
ctx: { params: Promise<Record<string, string>> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function req(method: string, path: string, body?: unknown, headers = {}) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function manage(
|
||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
||||
body?: unknown,
|
||||
headers = {},
|
||||
) {
|
||||
const route = await import("@/app/api/cycle/feed-subscription/route");
|
||||
const handler = route[method] as unknown as Handler;
|
||||
return handler(req(method, "/api/cycle/feed-subscription", body, headers), {
|
||||
params: Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
|
||||
async function rotate() {
|
||||
const { POST } =
|
||||
await import("@/app/api/cycle/feed-subscription/rotate/route");
|
||||
return (POST as unknown as Handler)(
|
||||
req("POST", "/api/cycle/feed-subscription/rotate"),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchFeed(
|
||||
segment: string,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const saved = new Map(cookieJar);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
const { GET } = await import("@/app/api/cycle/feed/[token]/route");
|
||||
const res = await (GET as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${segment}`, { headers }),
|
||||
{ params: Promise.resolve({ token: segment }) },
|
||||
);
|
||||
for (const [k, v] of saved) cookieJar.set(k, v);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function mint(userId: string) {
|
||||
await signIn(userId);
|
||||
const res = await manage("POST");
|
||||
expect(res.status).toBe(201);
|
||||
const { data } = await res.json();
|
||||
return data as {
|
||||
url: string;
|
||||
webcalUrl: string;
|
||||
feed: { tokenPrefix: string };
|
||||
};
|
||||
}
|
||||
|
||||
function segmentOf(url: string): string {
|
||||
return url.split("/api/cycle/feed/")[1];
|
||||
}
|
||||
|
||||
async function expect404(res: Response) {
|
||||
expect(res.status).toBe(404);
|
||||
expect((await res.json()).meta?.errorCode).toBe("cycle.feed.notFound");
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(db());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("turning the subscription on", () => {
|
||||
it("returns the URL once; the status never carries the hash or the URL", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await seedCycles(owner.id);
|
||||
const minted = await mint(owner.id);
|
||||
|
||||
expect(minted.url).toMatch(
|
||||
/^http:\/\/localhost\/api\/cycle\/feed\/hlf_[0-9a-f]{48}\.ics$/,
|
||||
);
|
||||
expect(minted.webcalUrl).toBe(minted.url.replace(/^http:/, "webcal:"));
|
||||
|
||||
const row = await db().cycleCalendarFeed.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
const raw = segmentOf(minted.url).replace(/\.ics$/, "");
|
||||
expect(row.tokenHash).not.toContain(raw);
|
||||
expect(row.tokenPrefix).toBe(raw.slice(0, 8));
|
||||
expect(row.createdById).toBe(owner.id);
|
||||
|
||||
const status = await (await manage("GET")).text();
|
||||
expect(status).not.toContain(raw);
|
||||
expect(status).not.toContain(row.tokenHash);
|
||||
expect(status).not.toContain("/api/cycle/feed/");
|
||||
expect(JSON.parse(status).data.feed.tokenPrefix).toBe(row.tokenPrefix);
|
||||
|
||||
const audit = await db().auditLog.findFirst({
|
||||
where: { action: "cycle.feed.create", userId: owner.id },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a second live feed; rotate leaves exactly one", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const first = await mint(owner.id);
|
||||
expect((await manage("POST")).status).toBe(409);
|
||||
|
||||
const res = await rotate();
|
||||
expect(res.status).toBe(201);
|
||||
const second = (await res.json()).data as { url: string };
|
||||
expect(second.url).not.toBe(first.url);
|
||||
|
||||
const live = await db().cycleCalendarFeed.findMany({
|
||||
where: { userId: owner.id, revokedAt: null },
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
await expect404(await fetchFeed(segmentOf(first.url)));
|
||||
expect((await fetchFeed(segmentOf(second.url))).status).toBe(200);
|
||||
expect(
|
||||
await db().auditLog.count({ where: { action: "cycle.feed.rotate" } }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("the partial unique index rejects a second live row written directly", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const base = {
|
||||
userId: owner.id,
|
||||
createdById: owner.id,
|
||||
tokenPrefix: "hlf_0000",
|
||||
};
|
||||
await db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "a" } });
|
||||
await expect(
|
||||
db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "b" } }),
|
||||
).rejects.toThrow();
|
||||
// A revoked row does not count.
|
||||
await db().cycleCalendarFeed.updateMany({
|
||||
where: { userId: owner.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "b" } });
|
||||
});
|
||||
|
||||
it("the table refuses a horizon outside 1..6", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await expect(
|
||||
db().cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
createdById: owner.id,
|
||||
tokenHash: "x",
|
||||
tokenPrefix: "hlf_0000",
|
||||
horizonCycles: 12,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses a Bearer token, even a cookie-equivalent one", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const { issueApiToken } = await import("@/lib/auth/issue-token");
|
||||
const issued = await issueApiToken({ userId: owner.id, name: "app" });
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
headerJar.set("authorization", `Bearer ${issued.token}`);
|
||||
const res = await manage("POST", undefined, {
|
||||
authorization: `Bearer ${issued.token}`,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).meta?.errorCode).toBe("cycle.feed.cookie_only");
|
||||
expect(await db().cycleCalendarFeed.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a session without a fresh proof", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await signIn(owner.id, new Date(Date.now() - 60 * 60 * 1000));
|
||||
const res = await manage("POST");
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta?.errorCode).toBe("auth.reproof.required");
|
||||
expect(await db().cycleCalendarFeed.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a READ delegate", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const reader = await makeUser("reader");
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: reader.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: reader.id });
|
||||
const session = await signIn(reader.id);
|
||||
await switchSessionTo(session.id, owner.id);
|
||||
|
||||
expect((await manage("POST")).status).toBe(403);
|
||||
expect((await manage("GET")).status).toBe(403);
|
||||
expect(await db().cycleCalendarFeed.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses the fertile window outside TRYING_TO_CONCEIVE", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await mint(owner.id);
|
||||
const res = await manage("PATCH", { includeFertile: true });
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta?.errorCode).toBe("cycle.feed.fertile_goal");
|
||||
});
|
||||
|
||||
it("never stores an idempotent replay of the mint", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await signIn(owner.id);
|
||||
const key = "feed-mint-replay-key-1";
|
||||
const first = await manage("POST", undefined, { "idempotency-key": key });
|
||||
expect(first.status).toBe(201);
|
||||
const second = await manage("POST", undefined, { "idempotency-key": key });
|
||||
// Not a replay of the URL: the second request ran and met the live feed.
|
||||
expect(second.headers.get("x-idempotent-replay")).not.toBe("true");
|
||||
expect(second.status).toBe(409);
|
||||
const cached = await db().idempotencyKey.findMany({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
for (const row of cached) {
|
||||
expect(row.responseBody == null || row.responseBody.length === 0).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching the feed", () => {
|
||||
it("serves iCalendar with an ETag and answers 304 to it", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await seedCycles(owner.id);
|
||||
const minted = await mint(owner.id);
|
||||
|
||||
const res = await fetchFeed(segmentOf(minted.url), {
|
||||
"user-agent": "Google-Calendar-Importer",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe(
|
||||
"text/calendar; charset=utf-8",
|
||||
);
|
||||
const body = await res.text();
|
||||
expect(body.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true);
|
||||
expect(body).toContain("SUMMARY:●");
|
||||
const etag = res.headers.get("etag")!;
|
||||
expect(etag).toMatch(/^"[0-9a-f]{64}"$/);
|
||||
|
||||
// Let the fire-and-forget statistics land before the conditional fetch.
|
||||
const { settleBackgroundTasks } =
|
||||
await import("@/lib/logging/background-tasks");
|
||||
await settleBackgroundTasks();
|
||||
const again = await fetchFeed(segmentOf(minted.url), {
|
||||
"if-none-match": etag,
|
||||
});
|
||||
expect(again.status).toBe(304);
|
||||
await settleBackgroundTasks();
|
||||
|
||||
const row = await db().cycleCalendarFeed.findFirstOrThrow({
|
||||
where: { userId: owner.id },
|
||||
});
|
||||
expect(row.fetchCount).toBe(2);
|
||||
expect(row.bodyHash).toBe(etag.slice(1, -1));
|
||||
|
||||
await signIn(owner.id);
|
||||
const status = (await (await manage("GET")).json()).data;
|
||||
expect(status.feed.lastFetcher).toBe("GOOGLE");
|
||||
expect(status.feed.fetchCount).toBe(2);
|
||||
});
|
||||
|
||||
it("answers the same 404 for every miss class", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await seedCycles(owner.id);
|
||||
const minted = await mint(owner.id);
|
||||
const live = segmentOf(minted.url);
|
||||
|
||||
await expect404(await fetchFeed(`hlf_${"0".repeat(48)}.ics`)); // unknown
|
||||
await expect404(await fetchFeed("hlf_short")); // malformed
|
||||
|
||||
// The operator's external-access switch.
|
||||
await db().appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", apiGlobal: false },
|
||||
update: { apiGlobal: false },
|
||||
});
|
||||
await expect404(await fetchFeed(live));
|
||||
await db().appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { apiGlobal: true },
|
||||
});
|
||||
|
||||
// The operator's module switch.
|
||||
await db().appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { moduleAvailabilityJson: { cycle: false } },
|
||||
});
|
||||
await expect404(await fetchFeed(live));
|
||||
await db().appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { moduleAvailabilityJson: {} },
|
||||
});
|
||||
|
||||
// The person's own toggle.
|
||||
await db().cycleProfile.update({
|
||||
where: { userId: owner.id },
|
||||
data: { cycleTrackingEnabled: false },
|
||||
});
|
||||
await expect404(await fetchFeed(live));
|
||||
await db().cycleProfile.update({
|
||||
where: { userId: owner.id },
|
||||
data: { cycleTrackingEnabled: true },
|
||||
});
|
||||
|
||||
// Back to serving, so the misses above are the gates and not the fixture.
|
||||
expect((await fetchFeed(live)).status).toBe(200);
|
||||
|
||||
// An account in its deletion window.
|
||||
await db().user.update({
|
||||
where: { id: owner.id },
|
||||
data: { deletionRequestedAt: new Date() },
|
||||
});
|
||||
await expect404(await fetchFeed(live));
|
||||
await db().user.update({
|
||||
where: { id: owner.id },
|
||||
data: { deletionRequestedAt: null },
|
||||
});
|
||||
|
||||
// Revoked.
|
||||
await signIn(owner.id);
|
||||
expect((await manage("DELETE")).status).toBe(200);
|
||||
await expect404(await fetchFeed(live));
|
||||
});
|
||||
|
||||
it("throttles a well-formed unknown token through the anonymous limit", async () => {
|
||||
let throttled = false;
|
||||
for (let i = 0; i < 130 && !throttled; i++) {
|
||||
const res = await fetchFeed(`hlf_${"1".repeat(48)}.ics`);
|
||||
if (res.status === 429) throttled = true;
|
||||
else expect(res.status).toBe(404);
|
||||
}
|
||||
expect(throttled).toBe(true);
|
||||
});
|
||||
|
||||
it("dies with the carrier's MANAGE grant, and a handover claim revokes it", async () => {
|
||||
const profile = await makeUser("profile", { managedProfileAt: new Date() });
|
||||
const guardian = await makeUser("guardian");
|
||||
await seedCycles(profile.id);
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: profile.id,
|
||||
granteeId: guardian.id,
|
||||
access: "MANAGE",
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
granteeId: guardian.id,
|
||||
});
|
||||
|
||||
const session = await signIn(guardian.id);
|
||||
await switchSessionTo(session.id, profile.id);
|
||||
const res = await manage("POST");
|
||||
expect(res.status).toBe(201);
|
||||
const { url } = (await res.json()).data as { url: string };
|
||||
const row = await db().cycleCalendarFeed.findFirstOrThrow({
|
||||
where: { userId: profile.id },
|
||||
});
|
||||
expect(row.createdById).toBe(guardian.id);
|
||||
expect((await fetchFeed(segmentOf(url))).status).toBe(200);
|
||||
|
||||
// Grant revoked: the next poll is a 404, with no write anywhere.
|
||||
await db().accountGrant.update({
|
||||
where: { id: grant.id },
|
||||
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
||||
});
|
||||
await expect404(await fetchFeed(segmentOf(url)));
|
||||
await db().accountGrant.update({
|
||||
where: { id: grant.id },
|
||||
data: { revokedAt: null, revokedBy: null },
|
||||
});
|
||||
expect((await fetchFeed(segmentOf(url))).status).toBe(200);
|
||||
|
||||
// The handover claim ends the carrier's feed outright.
|
||||
const { clearManagedProfileMarker } =
|
||||
await import("@/lib/managed-profiles/lifecycle");
|
||||
await clearManagedProfileMarker({ profileId: profile.id });
|
||||
const after = await db().cycleCalendarFeed.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
});
|
||||
expect(after.revokedAt).not.toBeNull();
|
||||
expect(
|
||||
await db().auditLog.count({
|
||||
where: { action: "cycle.feed.revoke.handover", userId: profile.id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("is removed when the carrier's account is deleted", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const carrier = await makeUser("carrier");
|
||||
await db().cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
createdById: carrier.id,
|
||||
tokenHash: "cascade",
|
||||
tokenPrefix: "hlf_0000",
|
||||
},
|
||||
});
|
||||
await db().user.delete({ where: { id: carrier.id } });
|
||||
expect(await db().cycleCalendarFeed.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is revoked by a credential remediation on either account", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
await mint(owner.id);
|
||||
const { destroyAllSessions } = await import("@/lib/auth/session");
|
||||
await destroyAllSessions(owner.id);
|
||||
expect(
|
||||
await db().cycleCalendarFeed.count({
|
||||
where: { userId: owner.id, revokedAt: null },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -280,7 +280,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2678,6 +2678,75 @@ proveManageRoute("POST /api/cycle/symptoms/custom", {
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
/** A live calendar feed on the record, minted as the owner would (#1350). */
|
||||
async function makeCycleFeed(ownerId: string, prefix = "hlf_matr") {
|
||||
await enableCycle(ownerId);
|
||||
return getPrismaClient().cycleCalendarFeed.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
createdById: ownerId,
|
||||
tokenHash: `matrix-${ownerId}-${prefix}`,
|
||||
tokenPrefix: prefix,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function liveCycleFeeds(ownerId: string) {
|
||||
return getPrismaClient().cycleCalendarFeed.findMany({
|
||||
where: { userId: ownerId, revokedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
proveManageRoute("POST /api/cycle/feed-subscription", {
|
||||
route: "/api/cycle/feed-subscription",
|
||||
prepare: async (ownerId) => ({ ownerId: await enableCycle(ownerId) }),
|
||||
ok: 201,
|
||||
auditAction: "cycle.feed.create",
|
||||
applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 1,
|
||||
});
|
||||
|
||||
proveManageRoute("PATCH /api/cycle/feed-subscription", {
|
||||
route: "/api/cycle/feed-subscription",
|
||||
action: "PATCH",
|
||||
prepare: async (ownerId) => {
|
||||
await makeCycleFeed(ownerId);
|
||||
return { ownerId };
|
||||
},
|
||||
body: () => ({ horizonCycles: 6, titleMode: "DESCRIPTIVE" }),
|
||||
ok: 200,
|
||||
auditAction: "cycle.feed.update",
|
||||
applied: async ({ ownerId }) => {
|
||||
const [feed] = await liveCycleFeeds(ownerId);
|
||||
return feed?.horizonCycles === 6 && feed.titleMode === "DESCRIPTIVE";
|
||||
},
|
||||
});
|
||||
|
||||
proveManageRoute("DELETE /api/cycle/feed-subscription", {
|
||||
route: "/api/cycle/feed-subscription",
|
||||
action: "DELETE",
|
||||
prepare: async (ownerId) => {
|
||||
await makeCycleFeed(ownerId);
|
||||
return { ownerId };
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "cycle.feed.revoke",
|
||||
applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 0,
|
||||
});
|
||||
|
||||
proveManageRoute("POST /api/cycle/feed-subscription/rotate", {
|
||||
route: "/api/cycle/feed-subscription/rotate",
|
||||
prepare: async (ownerId) => ({
|
||||
ownerId,
|
||||
previous: (await makeCycleFeed(ownerId)).id,
|
||||
}),
|
||||
ok: 201,
|
||||
auditAction: "cycle.feed.rotate",
|
||||
applied: async ({ ownerId, previous }) => {
|
||||
const live = await liveCycleFeeds(ownerId);
|
||||
return live.length === 1 && live[0].id !== previous;
|
||||
},
|
||||
});
|
||||
|
||||
proveManageRoute("POST /api/medications/categories", {
|
||||
route: "/api/medications/categories",
|
||||
prepare: async (ownerId) => ({ ownerId }),
|
||||
@@ -3020,7 +3089,7 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(93);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(97);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -3032,6 +3101,6 @@ describe("the complete MANAGE handler matrix", () => {
|
||||
afterAll(() => {
|
||||
if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return;
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(93);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(97);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
Reference in new issue
Block a user