docs(diagrams): add five Dracula-styled architecture diagrams

Five SVG diagrams anchor the architecture story across README, the
public doc site and the landing page:

- 01-data-flow: source → ingest → store → read → surface
- 02-coach-pipeline: question → snapshot → prompt → provider chain → cited reply
- 03-self-hosting-topology: reverse proxy → app + worker → Postgres + image registry
- 04-source-priority: per-metric ladder with audit-trail kept/dropped lanes
- 05-security-model: three concentric layers plus rate-limit / audit / SSRF rails

Hand-authored SVG, no external dependencies, mirrored into the
healthlog-docs and healthlog-landing repos so each surface ships its own
copy.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:14:41 +02:00
1 parent 6b2c63caea
commit 04aa2fc8b9
6 files changed
+722

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog data flow — device to insight</title>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<!-- Dracula background -->
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
<!-- Title -->
<text x="600" y="40" fill="#f8f8f2" font-size="22" font-weight="700" text-anchor="middle">From device to insight</text>
<text x="600" y="64" fill="#6272a4" font-size="13" text-anchor="middle">Every reading walks the same five-stage pipeline: source → ingest → store → read → surface</text>
<!-- Lane headers -->
<g font-size="12" fill="#6272a4" font-weight="600" letter-spacing="1">
<text x="120" y="105" text-anchor="middle">SOURCES</text>
<text x="360" y="105" text-anchor="middle">INGEST</text>
<text x="600" y="105" text-anchor="middle">STORE</text>
<text x="840" y="105" text-anchor="middle">READ</text>
<text x="1080" y="105" text-anchor="middle">SURFACE</text>
</g>
<!-- Lane separators -->
<g stroke="#44475a" stroke-dasharray="4 6" stroke-width="1">
<line x1="240" y1="120" x2="240" y2="560" />
<line x1="480" y1="120" x2="480" y2="560" />
<line x1="720" y1="120" x2="720" y2="560" />
<line x1="960" y1="120" x2="960" y2="560" />
</g>
<!-- SOURCES (cyan #8be9fd) -->
<g font-size="13" fill="#f8f8f2">
<rect x="30" y="140" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="165" text-anchor="middle" font-weight="600">Withings devices</text>
<text x="120" y="183" text-anchor="middle" fill="#bd93f9" font-size="11">Scales · BPM · ScanWatch</text>
<rect x="30" y="216" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="241" text-anchor="middle" font-weight="600">Apple Health export</text>
<text x="120" y="259" text-anchor="middle" fill="#bd93f9" font-size="11">export.zip · multi-GB</text>
<rect x="30" y="292" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="317" text-anchor="middle" font-weight="600">iPhone HealthKit</text>
<text x="120" y="335" text-anchor="middle" fill="#bd93f9" font-size="11">native batch (v1.5)</text>
<rect x="30" y="368" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="393" text-anchor="middle" font-weight="600">Manual entry</text>
<text x="120" y="411" text-anchor="middle" fill="#bd93f9" font-size="11">PWA · iOS Shortcuts</text>
<rect x="30" y="444" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="469" text-anchor="middle" font-weight="600">moodLog.app</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">webhook</text>
</g>
<!-- INGEST (orange #ffb86c) -->
<g font-size="13" fill="#f8f8f2">
<rect x="270" y="170" width="180" height="56" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="360" y="195" text-anchor="middle" font-weight="600">/api/withings/webhook</text>
<text x="360" y="213" text-anchor="middle" fill="#bd93f9" font-size="11">+ /sync</text>
<rect x="270" y="246" width="180" height="56" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="360" y="271" text-anchor="middle" font-weight="600">/import/apple-health-export</text>
<text x="360" y="289" text-anchor="middle" fill="#bd93f9" font-size="11">streaming · idempotent</text>
<rect x="270" y="322" width="180" height="56" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="360" y="347" text-anchor="middle" font-weight="600">/api/measurements/batch</text>
<text x="360" y="365" text-anchor="middle" fill="#bd93f9" font-size="11">native batch ingest</text>
<rect x="270" y="398" width="180" height="56" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="360" y="423" text-anchor="middle" font-weight="600">Dedup + source-priority</text>
<text x="360" y="441" text-anchor="middle" fill="#bd93f9" font-size="11">resolver</text>
</g>
<!-- STORE (pink #ff79c6) -->
<g font-size="13" fill="#f8f8f2">
<rect x="510" y="200" width="180" height="76" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="227" text-anchor="middle" font-weight="600">Measurement</text>
<text x="600" y="247" text-anchor="middle" fill="#bd93f9" font-size="11">one row per sample</text>
<text x="600" y="264" text-anchor="middle" fill="#6272a4" font-size="11">(userId, type, source, t)</text>
<rect x="510" y="296" width="180" height="76" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="323" text-anchor="middle" font-weight="600">MeasurementRollup</text>
<text x="600" y="343" text-anchor="middle" fill="#bd93f9" font-size="11">DAY · WEEK · MONTH</text>
<text x="600" y="360" text-anchor="middle" fill="#6272a4" font-size="11">pre-aggregated buckets</text>
<rect x="510" y="392" width="180" height="56" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 16</text>
<text x="600" y="435" text-anchor="middle" fill="#bd93f9" font-size="11">encrypted at rest</text>
</g>
<!-- READ (green #50fa7b) -->
<g font-size="13" fill="#f8f8f2">
<rect x="750" y="200" width="180" height="56" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="840" y="225" text-anchor="middle" font-weight="600">Rollup probe</text>
<text x="840" y="243" text-anchor="middle" fill="#bd93f9" font-size="11">DAY-bucket fast path</text>
<rect x="750" y="276" width="180" height="56" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="840" y="301" text-anchor="middle" font-weight="600">Live SQL fallback</text>
<text x="840" y="319" text-anchor="middle" fill="#bd93f9" font-size="11">slope · r² · sd</text>
<rect x="750" y="352" width="180" height="56" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="840" y="377" text-anchor="middle" font-weight="600">Wide-event cache</text>
<text x="840" y="395" text-anchor="middle" fill="#bd93f9" font-size="11">cache.&lt;name&gt;.outcome</text>
</g>
<!-- SURFACES (purple #bd93f9) -->
<g font-size="13" fill="#f8f8f2">
<rect x="990" y="170" width="180" height="56" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1080" y="195" text-anchor="middle" font-weight="600">Dashboard tiles</text>
<text x="1080" y="213" text-anchor="middle" fill="#bd93f9" font-size="11">trends · ranges</text>
<rect x="990" y="246" width="180" height="56" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1080" y="271" text-anchor="middle" font-weight="600">Insights cards</text>
<text x="1080" y="289" text-anchor="middle" fill="#bd93f9" font-size="11">evidence-grounded</text>
<rect x="990" y="322" width="180" height="56" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1080" y="347" text-anchor="middle" font-weight="600">AI Coach</text>
<text x="1080" y="365" text-anchor="middle" fill="#bd93f9" font-size="11">cited replies</text>
<rect x="990" y="398" width="180" height="56" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1080" y="423" text-anchor="middle" font-weight="600">Doctor PDF</text>
<text x="1080" y="441" text-anchor="middle" fill="#bd93f9" font-size="11">client-side jsPDF</text>
</g>
<!-- Arrows: sources → ingest -->
<g stroke="#8be9fd" stroke-width="1.5" fill="none" marker-end="url(#arr-cyan)">
<path d="M210 168 C 235 168, 245 198, 270 198" />
<path d="M210 244 C 235 244, 245 274, 270 274" />
<path d="M210 320 C 235 320, 245 350, 270 350" />
<path d="M210 396 C 235 396, 245 426, 270 426" />
<path d="M210 472 C 235 472, 245 426, 270 426" />
</g>
<!-- Arrows: ingest → store (all funnel into priority resolver, then to tables) -->
<g stroke="#ffb86c" stroke-width="1.5" fill="none" marker-end="url(#arr-orange)">
<path d="M450 198 C 480 198, 490 238, 510 238" />
<path d="M450 274 C 480 274, 490 238, 510 238" />
<path d="M450 350 C 480 350, 490 238, 510 238" />
<path d="M450 426 C 480 426, 490 334, 510 334" />
</g>
<!-- Arrow: Measurement → Rollup (worker) -->
<g stroke="#ff79c6" stroke-width="1.5" fill="none" marker-end="url(#arr-pink)">
<path d="M600 276 C 600 286, 600 286, 600 296" />
</g>
<text x="612" y="290" fill="#6272a4" font-size="10">pg-boss worker</text>
<!-- Arrows: store → read -->
<g stroke="#50fa7b" stroke-width="1.5" fill="none" marker-end="url(#arr-green)">
<path d="M690 334 C 720 334, 730 228, 750 228" />
<path d="M690 238 C 720 238, 730 304, 750 304" />
<path d="M690 420 C 720 420, 730 380, 750 380" />
</g>
<!-- Arrows: read → surfaces -->
<g stroke="#bd93f9" stroke-width="1.5" fill="none" marker-end="url(#arr-purple)">
<path d="M930 228 C 960 228, 970 198, 990 198" />
<path d="M930 228 C 960 228, 970 274, 990 274" />
<path d="M930 304 C 960 304, 970 350, 990 350" />
<path d="M930 380 C 960 380, 970 426, 990 426" />
</g>
<!-- Footnote -->
<text x="600" y="590" fill="#6272a4" font-size="11" text-anchor="middle">Source-priority ladder runs at ingest. Live SQL is the safety net when the rollup is cold or the metric ask is too slope/variance-heavy for a DAY bucket.</text>
<defs>
<marker id="arr-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8be9fd" /></marker>
<marker id="arr-orange" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ffb86c" /></marker>
<marker id="arr-pink" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff79c6" /></marker>
<marker id="arr-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#50fa7b" /></marker>
<marker id="arr-purple" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#bd93f9" /></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+148
View File
@@ -0,0 +1,148 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">AI Coach prompt pipeline</title>
<desc id="desc">A user question feeds the snapshot builder, which pulls the recent metric, medication and mood windows. The system prompt assembler combines them with a locale-aware structured-output schema, then the provider chain (ChatGPT OAuth, OpenAI, Anthropic, Local) tries each entry in order. The response parser extracts claims plus citations, validates against the schema, and renders a cited reply with mini-charts.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
<text x="600" y="40" fill="#f8f8f2" font-size="22" font-weight="700" text-anchor="middle">AI Coach prompt pipeline</text>
<text x="600" y="64" fill="#6272a4" font-size="13" text-anchor="middle">Why every reply cites the measurement it came from — not just an LLM call.</text>
<!-- USER QUESTION (cyan) -->
<g>
<rect x="40" y="120" width="200" height="80" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="140" y="148" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">User question</text>
<text x="140" y="170" text-anchor="middle" fill="#bd93f9" font-size="11">/insights drawer</text>
<text x="140" y="186" text-anchor="middle" fill="#6272a4" font-size="11">"Why is my BP higher this week?"</text>
</g>
<!-- SNAPSHOT BUILDER (orange) -->
<g>
<rect x="280" y="100" width="220" height="120" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="390" y="128" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Snapshot builder</text>
<text x="390" y="152" text-anchor="middle" fill="#bd93f9" font-size="11">last-N days metrics</text>
<text x="390" y="170" text-anchor="middle" fill="#bd93f9" font-size="11">+ medication schedule</text>
<text x="390" y="188" text-anchor="middle" fill="#bd93f9" font-size="11">+ mood + targets</text>
<text x="390" y="208" text-anchor="middle" fill="#6272a4" font-size="11">60s LRU per (userId, scope)</text>
</g>
<!-- PROMPT ASSEMBLER (pink) -->
<g>
<rect x="540" y="100" width="220" height="120" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="650" y="128" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Prompt assembler</text>
<text x="650" y="152" text-anchor="middle" fill="#bd93f9" font-size="11">system prompt</text>
<text x="650" y="170" text-anchor="middle" fill="#bd93f9" font-size="11">+ locale (en/de)</text>
<text x="650" y="188" text-anchor="middle" fill="#bd93f9" font-size="11">+ Zod citation schema</text>
<text x="650" y="208" text-anchor="middle" fill="#6272a4" font-size="11">PROMPT_VERSION 4.36.0</text>
</g>
<!-- PROVIDER CHAIN (purple) -->
<g>
<rect x="800" y="100" width="360" height="280" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="980" y="128" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Provider chain</text>
<text x="980" y="148" text-anchor="middle" fill="#6272a4" font-size="11">first non-error wins · hard fail cascades</text>
<!-- Entries -->
<g font-size="12" fill="#f8f8f2">
<rect x="820" y="170" width="320" height="38" rx="8" fill="#282a36" stroke="#bd93f9" stroke-width="1" />
<text x="840" y="194" font-weight="600">1.</text>
<text x="870" y="194">ChatGPT OAuth (Codex)</text>
<text x="1120" y="194" text-anchor="end" fill="#50fa7b" font-size="11">subscription</text>
<rect x="820" y="216" width="320" height="38" rx="8" fill="#282a36" stroke="#bd93f9" stroke-width="1" />
<text x="840" y="240" font-weight="600">2.</text>
<text x="870" y="240">OpenAI direct (BYOK)</text>
<text x="1120" y="240" text-anchor="end" fill="#ffb86c" font-size="11">per-token</text>
<rect x="820" y="262" width="320" height="38" rx="8" fill="#282a36" stroke="#bd93f9" stroke-width="1" />
<text x="840" y="286" font-weight="600">3.</text>
<text x="870" y="286">Anthropic Claude (BYOK)</text>
<text x="1120" y="286" text-anchor="end" fill="#ffb86c" font-size="11">per-token</text>
<rect x="820" y="308" width="320" height="38" rx="8" fill="#282a36" stroke="#bd93f9" stroke-width="1" />
<text x="840" y="332" font-weight="600">4.</text>
<text x="870" y="332">Local (Ollama · LM Studio · vLLM)</text>
<text x="1120" y="332" text-anchor="end" fill="#50fa7b" font-size="11">on network</text>
<rect x="820" y="354" width="320" height="20" rx="6" fill="#282a36" stroke="#6272a4" stroke-width="1" stroke-dasharray="3 3" />
<text x="980" y="368" text-anchor="middle" fill="#6272a4" font-size="11">5. Admin-shared OpenAI (fallback)</text>
</g>
</g>
<!-- RESPONSE PARSER (green) -->
<g>
<rect x="540" y="420" width="220" height="120" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="650" y="448" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Response parser</text>
<text x="650" y="472" text-anchor="middle" fill="#bd93f9" font-size="11">Zod schema validate</text>
<text x="650" y="490" text-anchor="middle" fill="#bd93f9" font-size="11">extract claims + cites</text>
<text x="650" y="508" text-anchor="middle" fill="#bd93f9" font-size="11">server-computed score</text>
<text x="650" y="528" text-anchor="middle" fill="#ff5555" font-size="11">corrective retry on 422</text>
</g>
<!-- CITED REPLY (pink) -->
<g>
<rect x="280" y="420" width="220" height="120" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="390" y="448" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Cited reply</text>
<text x="390" y="472" text-anchor="middle" fill="#bd93f9" font-size="11">prose first</text>
<text x="390" y="490" text-anchor="middle" fill="#bd93f9" font-size="11">collapsible evidence</text>
<text x="390" y="508" text-anchor="middle" fill="#bd93f9" font-size="11">mini-charts</text>
<text x="390" y="528" text-anchor="middle" fill="#bd93f9" font-size="11">ESH/ESC/WHO/DGE link</text>
</g>
<!-- DRAWER (cyan) -->
<g>
<rect x="40" y="430" width="200" height="80" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="140" y="458" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">/insights drawer</text>
<text x="140" y="480" text-anchor="middle" fill="#bd93f9" font-size="11">streaming render</text>
<text x="140" y="498" text-anchor="middle" fill="#6272a4" font-size="11">source chips · feedback</text>
</g>
<!-- CACHE box -->
<g>
<rect x="40" y="280" width="200" height="80" rx="10" fill="#44475a" stroke="#ff5555" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="140" y="308" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">24h insight cache</text>
<text x="140" y="330" text-anchor="middle" fill="#bd93f9" font-size="11">snapshot hash + version</text>
<text x="140" y="346" text-anchor="middle" fill="#6272a4" font-size="11">short-circuits provider call</text>
</g>
<!-- Arrows -->
<g stroke="#8be9fd" stroke-width="1.5" fill="none" marker-end="url(#a-c)">
<path d="M240 160 L 280 160" />
</g>
<g stroke="#ffb86c" stroke-width="1.5" fill="none" marker-end="url(#a-o)">
<path d="M500 160 L 540 160" />
</g>
<g stroke="#ff79c6" stroke-width="1.5" fill="none" marker-end="url(#a-p)">
<path d="M760 160 L 800 160" />
</g>
<g stroke="#bd93f9" stroke-width="1.5" fill="none" marker-end="url(#a-pu)">
<!-- provider chain → parser -->
<path d="M820 320 C 780 320, 760 420, 760 480" />
</g>
<g stroke="#50fa7b" stroke-width="1.5" fill="none" marker-end="url(#a-g)">
<path d="M540 480 L 500 480" />
</g>
<g stroke="#ff79c6" stroke-width="1.5" fill="none" marker-end="url(#a-pi)">
<path d="M280 480 L 240 470" />
</g>
<!-- cache loop -->
<g stroke="#ff5555" stroke-width="1.5" stroke-dasharray="4 4" fill="none" marker-end="url(#a-r)">
<path d="M390 220 L 200 280" />
<path d="M200 360 C 250 380, 280 410, 300 420" />
</g>
<text x="220" y="395" fill="#ff5555" font-size="10">hit → render</text>
<text x="290" y="252" fill="#ff5555" font-size="10">miss → assemble</text>
<!-- Footnote -->
<text x="600" y="580" fill="#6272a4" font-size="11" text-anchor="middle">Every claim renders with its source chip (metric · window · n) so the user can audit which slice of data backed each sentence.</text>
<defs>
<marker id="a-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8be9fd" /></marker>
<marker id="a-o" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ffb86c" /></marker>
<marker id="a-p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff79c6" /></marker>
<marker id="a-pu" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#bd93f9" /></marker>
<marker id="a-g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#50fa7b" /></marker>
<marker id="a-pi" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff79c6" /></marker>
<marker id="a-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff5555" /></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+132
View File
@@ -0,0 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog self-hosting topology</title>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
<text x="600" y="40" fill="#f8f8f2" font-size="22" font-weight="700" text-anchor="middle">Self-hosting topology</text>
<text x="600" y="64" fill="#6272a4" font-size="13" text-anchor="middle">What runs where on a single-host install — and what the optional pieces add.</text>
<!-- Outer: Your host -->
<g>
<rect x="220" y="100" width="760" height="440" rx="14" fill="none" stroke="#bd93f9" stroke-width="1.5" stroke-dasharray="6 4" />
<text x="600" y="124" text-anchor="middle" fill="#bd93f9" font-size="13" font-weight="600">Your host (NAS · homelab · VPS)</text>
</g>
<!-- Internet (cyan) -->
<g>
<ellipse cx="120" cy="280" rx="80" ry="50" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Internet</text>
<text x="120" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">browsers · iOS app</text>
</g>
<!-- Reverse proxy (orange) -->
<g>
<rect x="260" y="240" width="180" height="80" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="350" y="266" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Reverse proxy</text>
<text x="350" y="286" text-anchor="middle" fill="#bd93f9" font-size="11">Caddy · Traefik · Nginx</text>
<text x="350" y="302" text-anchor="middle" fill="#6272a4" font-size="11">TLS · HSTS · CSP</text>
</g>
<!-- Next.js app (pink) -->
<g>
<rect x="480" y="200" width="220" height="80" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="590" y="226" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Next.js app</text>
<text x="590" y="246" text-anchor="middle" fill="#bd93f9" font-size="11">port 3000 · App Router</text>
<text x="590" y="262" text-anchor="middle" fill="#6272a4" font-size="11">HEALTHLOG_PROCESS_TYPE=web</text>
</g>
<!-- pg-boss worker (pink) -->
<g>
<rect x="480" y="300" width="220" height="80" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="590" y="326" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">pg-boss worker</text>
<text x="590" y="346" text-anchor="middle" fill="#bd93f9" font-size="11">reminders · insights · imports</text>
<text x="590" y="362" text-anchor="middle" fill="#6272a4" font-size="11">HEALTHLOG_PROCESS_TYPE=worker</text>
</g>
<!-- Postgres (green) -->
<g>
<rect x="760" y="250" width="200" height="80" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 16</text>
<text x="860" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">26 Prisma models</text>
<text x="860" y="312" text-anchor="middle" fill="#6272a4" font-size="11">+ pg-boss queues</text>
</g>
<!-- GHCR (purple) -->
<g>
<rect x="260" y="420" width="220" height="80" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="370" y="446" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">GitHub Container Registry</text>
<text x="370" y="466" text-anchor="middle" fill="#bd93f9" font-size="11">multi-arch image (amd64/arm64)</text>
<text x="370" y="482" text-anchor="middle" fill="#6272a4" font-size="11">ghcr.io/mbombeck/healthlog</text>
</g>
<!-- Coolify (purple) -->
<g>
<rect x="520" y="420" width="180" height="80" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="610" y="446" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Coolify (optional)</text>
<text x="610" y="466" text-anchor="middle" fill="#bd93f9" font-size="11">watch digest · pull · deploy</text>
<text x="610" y="482" text-anchor="middle" fill="#ff5555" font-size="11">pull_policy: always</text>
</g>
<!-- S3 backup (orange) -->
<g>
<rect x="740" y="420" width="220" height="80" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="850" y="446" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">S3-compatible backup</text>
<text x="850" y="466" text-anchor="middle" fill="#bd93f9" font-size="11">R2 · B2 · MinIO · AWS</text>
<text x="850" y="482" text-anchor="middle" fill="#6272a4" font-size="11">AES-GCM weekly off-host</text>
</g>
<!-- Test result probe (right side) -->
<g>
<rect x="1010" y="240" width="160" height="100" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="1090" y="266" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Withings</text>
<text x="1090" y="282" text-anchor="middle" fill="#6272a4" font-size="11">Apple Health</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">moodLog.app</text>
<text x="1090" y="314" text-anchor="middle" fill="#6272a4" font-size="11">AI provider</text>
<text x="1090" y="332" text-anchor="middle" fill="#bd93f9" font-size="11">(per-user)</text>
</g>
<!-- Arrows -->
<g stroke="#8be9fd" stroke-width="1.5" fill="none" marker-end="url(#a3-c)">
<path d="M200 280 L 260 280" />
</g>
<text x="220" y="270" fill="#6272a4" font-size="10">HTTPS</text>
<g stroke="#ffb86c" stroke-width="1.5" fill="none" marker-end="url(#a3-o)">
<path d="M440 270 C 460 260, 470 240, 480 240" />
</g>
<g stroke="#ff79c6" stroke-width="1.5" fill="none" marker-end="url(#a3-p)">
<path d="M700 240 L 760 270" />
<path d="M700 340 L 760 310" />
<path d="M590 280 L 590 300" />
</g>
<text x="510" y="295" fill="#6272a4" font-size="10">shared DB</text>
<g stroke="#bd93f9" stroke-width="1.5" fill="none" marker-end="url(#a3-pu)">
<path d="M480 460 L 520 460" />
<path d="M700 440 C 680 380, 640 360, 590 360" />
</g>
<text x="700" y="395" fill="#6272a4" font-size="10">image pull</text>
<g stroke="#ffb86c" stroke-width="1.5" fill="none" marker-end="url(#a3-o2)">
<path d="M860 420 L 860 330" />
</g>
<text x="870" y="385" fill="#6272a4" font-size="10">nightly dump</text>
<g stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" fill="none" marker-end="url(#a3-c2)">
<path d="M700 220 C 800 200, 1000 220, 1080 240" />
</g>
<text x="900" y="195" fill="#6272a4" font-size="10">outbound integrations</text>
<text x="600" y="585" fill="#6272a4" font-size="11" text-anchor="middle">Single-process default: web + worker share one container. Split via HEALTHLOG_PROCESS_TYPE for horizontal scale.</text>
<defs>
<marker id="a3-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8be9fd" /></marker>
<marker id="a3-o" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ffb86c" /></marker>
<marker id="a3-p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff79c6" /></marker>
<marker id="a3-pu" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#bd93f9" /></marker>
<marker id="a3-o2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ffb86c" /></marker>
<marker id="a3-c2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8be9fd" /></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

+126
View File
@@ -0,0 +1,126 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">Source-priority ladder</title>
<desc id="desc">Three sources (Apple Watch via HealthKit, Withings device, manual entry) can all submit a reading for the same metric. The resolver picks one canonical value per user-day based on metric type — cumulative metrics (steps, energy, distance) prefer Apple Health, point measurements (weight, BP, SpO₂) prefer Withings. Losing rows stay in the audit trail but drop out of aggregation.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
<text x="600" y="40" fill="#f8f8f2" font-size="22" font-weight="700" text-anchor="middle">Source-priority ladder</text>
<text x="600" y="64" fill="#6272a4" font-size="13" text-anchor="middle">Why your steps don't triple-count when an iPhone, a Withings watch and a manual entry all log the same day.</text>
<!-- Day strip background -->
<g>
<rect x="60" y="100" width="280" height="440" rx="14" fill="#21222c" stroke="#44475a" stroke-width="1" stroke-dasharray="4 4" />
<text x="200" y="124" text-anchor="middle" fill="#6272a4" font-size="13" font-weight="600">Same day · same metric · three sources</text>
</g>
<!-- Source 1: Apple Health (cyan) -->
<g>
<rect x="90" y="150" width="220" height="80" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="200" y="178" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Apple Watch (HK)</text>
<text x="200" y="198" text-anchor="middle" fill="#bd93f9" font-size="11">10 432 steps · 76 kg · 132/81 mmHg</text>
<text x="200" y="214" text-anchor="middle" fill="#6272a4" font-size="11">source = APPLE_HEALTH</text>
</g>
<!-- Source 2: Withings (orange) -->
<g>
<rect x="90" y="250" width="220" height="80" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="200" y="278" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Withings scale + BPM</text>
<text x="200" y="298" text-anchor="middle" fill="#bd93f9" font-size="11">9 870 steps · 75.8 kg · 134/82 mmHg</text>
<text x="200" y="314" text-anchor="middle" fill="#6272a4" font-size="11">source = WITHINGS</text>
</g>
<!-- Source 3: Manual (pink) -->
<g>
<rect x="90" y="350" width="220" height="80" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="200" y="378" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">Manual entry</text>
<text x="200" y="398" text-anchor="middle" fill="#bd93f9" font-size="11">76.0 kg (after-shower weigh-in)</text>
<text x="200" y="414" text-anchor="middle" fill="#6272a4" font-size="11">source = MANUAL</text>
</g>
<!-- Source 4: Import (purple, dim) -->
<g>
<rect x="90" y="450" width="220" height="60" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.7" />
<text x="200" y="476" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Legacy CSV / JSON import</text>
<text x="200" y="494" text-anchor="middle" fill="#6272a4" font-size="11">source = IMPORT (lowest rank)</text>
</g>
<!-- Resolver -->
<g>
<rect x="420" y="220" width="200" height="200" rx="14" fill="#44475a" stroke="#bd93f9" stroke-width="2" />
<text x="520" y="248" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="700">Source-priority resolver</text>
<text x="520" y="270" text-anchor="middle" fill="#6272a4" font-size="11">src/lib/validations/source-priority.ts</text>
<text x="520" y="298" text-anchor="middle" fill="#bd93f9" font-size="11" font-weight="600">Cumulative metrics</text>
<text x="520" y="316" text-anchor="middle" fill="#f8f8f2" font-size="11">APPLE_HEALTH ≻ WITHINGS</text>
<text x="520" y="332" text-anchor="middle" fill="#f8f8f2" font-size="11">≻ MANUAL ≻ IMPORT</text>
<text x="520" y="360" text-anchor="middle" fill="#bd93f9" font-size="11" font-weight="600">Point measurements</text>
<text x="520" y="378" text-anchor="middle" fill="#f8f8f2" font-size="11">WITHINGS ≻ APPLE_HEALTH</text>
<text x="520" y="394" text-anchor="middle" fill="#f8f8f2" font-size="11">≻ MANUAL ≻ IMPORT</text>
</g>
<!-- Override box -->
<g>
<rect x="420" y="450" width="200" height="80" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="3 3" />
<text x="520" y="476" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Per-user override</text>
<text x="520" y="494" text-anchor="middle" fill="#bd93f9" font-size="11">User.sourcePriorityJson</text>
<text x="520" y="510" text-anchor="middle" fill="#6272a4" font-size="11">/settings/thresholds → Sources</text>
</g>
<!-- Output swimlane: kept -->
<g>
<rect x="700" y="150" width="450" height="160" rx="14" fill="#21222c" stroke="#50fa7b" stroke-width="1.5" />
<text x="925" y="174" text-anchor="middle" fill="#50fa7b" font-size="13" font-weight="700">Kept — counted in aggregation</text>
<rect x="720" y="190" width="200" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="820" y="210" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">Steps → Apple Health</text>
<text x="820" y="226" text-anchor="middle" fill="#bd93f9" font-size="11">10 432 (cumulative winner)</text>
<rect x="940" y="190" width="200" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="1040" y="210" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">Weight → Withings</text>
<text x="1040" y="226" text-anchor="middle" fill="#bd93f9" font-size="11">75.8 kg (point winner)</text>
<rect x="720" y="250" width="200" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="820" y="270" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">BP → Withings</text>
<text x="820" y="286" text-anchor="middle" fill="#bd93f9" font-size="11">134/82 mmHg (point winner)</text>
</g>
<!-- Output swimlane: dropped -->
<g>
<rect x="700" y="340" width="450" height="190" rx="14" fill="#21222c" stroke="#ff5555" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="925" y="364" text-anchor="middle" fill="#ff5555" font-size="13" font-weight="700">Dropped from aggregation — audit trail kept</text>
<rect x="720" y="380" width="200" height="40" rx="8" fill="#44475a" stroke="#ff5555" stroke-width="1" opacity="0.85" />
<text x="820" y="404" text-anchor="middle" fill="#f8f8f2" font-size="11">9 870 steps (Withings)</text>
<rect x="940" y="380" width="200" height="40" rx="8" fill="#44475a" stroke="#ff5555" stroke-width="1" opacity="0.85" />
<text x="1040" y="404" text-anchor="middle" fill="#f8f8f2" font-size="11">76 kg (Apple Health)</text>
<rect x="720" y="430" width="200" height="40" rx="8" fill="#44475a" stroke="#ff5555" stroke-width="1" opacity="0.85" />
<text x="820" y="454" text-anchor="middle" fill="#f8f8f2" font-size="11">132/81 mmHg (Apple Health)</text>
<rect x="940" y="430" width="200" height="40" rx="8" fill="#44475a" stroke="#ff5555" stroke-width="1" opacity="0.85" />
<text x="1040" y="454" text-anchor="middle" fill="#f8f8f2" font-size="11">76 kg (Manual)</text>
<text x="925" y="500" text-anchor="middle" fill="#6272a4" font-size="11">All rows visible in /measurements list. None feed dashboard, insights, or Coach for the conflict day.</text>
</g>
<!-- Arrows from sources to resolver -->
<g stroke="#8be9fd" stroke-width="1.5" fill="none" marker-end="url(#a4-c)"><path d="M310 190 C 370 190, 380 280, 420 280" /></g>
<g stroke="#ffb86c" stroke-width="1.5" fill="none" marker-end="url(#a4-o)"><path d="M310 290 L 420 310" /></g>
<g stroke="#ff79c6" stroke-width="1.5" fill="none" marker-end="url(#a4-p)"><path d="M310 390 C 370 390, 380 350, 420 350" /></g>
<g stroke="#bd93f9" stroke-width="1.5" fill="none" marker-end="url(#a4-pu)" opacity="0.7"><path d="M310 480 C 370 480, 380 400, 420 400" /></g>
<!-- Arrows from resolver to swimlanes -->
<g stroke="#50fa7b" stroke-width="1.5" fill="none" marker-end="url(#a4-g)"><path d="M620 270 C 660 240, 670 220, 700 220" /></g>
<g stroke="#ff5555" stroke-width="1.5" fill="none" marker-end="url(#a4-r)"><path d="M620 380 C 660 410, 670 420, 700 420" /></g>
<defs>
<marker id="a4-c" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#8be9fd" /></marker>
<marker id="a4-o" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ffb86c" /></marker>
<marker id="a4-p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff79c6" /></marker>
<marker id="a4-pu" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#bd93f9" /></marker>
<marker id="a4-g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#50fa7b" /></marker>
<marker id="a4-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff5555" /></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

+119
View File
@@ -0,0 +1,119 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog security model</title>
<desc id="desc">Three concentric layers: an outer auth perimeter (passkey-first WebAuthn, password fallback with Argon2id), a session perimeter (HttpOnly SameSite=Strict cookie, Postgres-backed sessions with sliding expiry), and the encrypted core (AES-256-GCM with versioned keys for secrets at rest). Side rails cover the rate limiter, audit log, HMAC API tokens, CSP/HSTS headers and SSRF-guarded outbound test endpoints.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
<text x="600" y="40" fill="#f8f8f2" font-size="22" font-weight="700" text-anchor="middle">Security model</text>
<text x="600" y="64" fill="#6272a4" font-size="13" text-anchor="middle">The path a request takes through the auth, session and encryption layers.</text>
<!-- Outer auth layer -->
<g>
<rect x="240" y="100" width="720" height="440" rx="20" fill="none" stroke="#8be9fd" stroke-width="2" />
<text x="270" y="124" fill="#8be9fd" font-size="13" font-weight="700">Layer 1 — Auth perimeter (WebAuthn first)</text>
</g>
<!-- Session layer -->
<g>
<rect x="320" y="160" width="560" height="320" rx="18" fill="none" stroke="#ffb86c" stroke-width="2" />
<text x="350" y="184" fill="#ffb86c" font-size="13" font-weight="700">Layer 2 — Session perimeter (server-side sessions)</text>
</g>
<!-- Encryption core -->
<g>
<rect x="420" y="220" width="360" height="220" rx="16" fill="none" stroke="#50fa7b" stroke-width="2" />
<text x="450" y="244" fill="#50fa7b" font-size="13" font-weight="700">Layer 3 — Encrypted core (AES-256-GCM)</text>
<rect x="450" y="265" width="300" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="600" y="285" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">Secrets at rest</text>
<text x="600" y="302" text-anchor="middle" fill="#bd93f9" font-size="11">Withings tokens · provider keys · VAPID · Codex</text>
<rect x="450" y="325" width="140" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="520" y="345" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">Versioned keys</text>
<text x="520" y="362" text-anchor="middle" fill="#bd93f9" font-size="11">ENCRYPTION_KEYS</text>
<rect x="610" y="325" width="140" height="50" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="680" y="345" text-anchor="middle" fill="#f8f8f2" font-size="12" font-weight="600">Online rotation</text>
<text x="680" y="362" text-anchor="middle" fill="#bd93f9" font-size="11">rotate-encryption-key</text>
<rect x="450" y="385" width="300" height="40" rx="8" fill="#44475a" stroke="#50fa7b" stroke-width="1" />
<text x="600" y="409" text-anchor="middle" fill="#bd93f9" font-size="11">Bearer tokens stored as HMAC-SHA256 digests</text>
</g>
<!-- Auth nodes (cyan) -->
<g>
<rect x="260" y="200" width="180" height="60" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="350" y="224" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Passkey (WebAuthn)</text>
<text x="350" y="244" text-anchor="middle" fill="#bd93f9" font-size="11">SimpleWebAuthn v13</text>
<rect x="260" y="280" width="180" height="60" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="350" y="304" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Password fallback</text>
<text x="350" y="324" text-anchor="middle" fill="#bd93f9" font-size="11">Argon2id + zxcvbn</text>
</g>
<!-- Session nodes (orange) -->
<g>
<rect x="340" y="380" width="220" height="60" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="450" y="404" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">HttpOnly · SameSite=Strict</text>
<text x="450" y="424" text-anchor="middle" fill="#bd93f9" font-size="11">cookie · 30d sliding expiry</text>
<rect x="640" y="380" width="220" height="60" rx="10" fill="#44475a" stroke="#ffb86c" stroke-width="1.5" />
<text x="750" y="404" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Postgres sessions</text>
<text x="750" y="424" text-anchor="middle" fill="#bd93f9" font-size="11">requireAuth · requireAdmin</text>
</g>
<!-- Side rails -->
<g>
<rect x="40" y="160" width="180" height="60" rx="10" fill="#44475a" stroke="#ff5555" stroke-width="1.5" />
<text x="130" y="184" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Rate limiter</text>
<text x="130" y="204" text-anchor="middle" fill="#bd93f9" font-size="11">sliding window · per IP+user</text>
<rect x="40" y="240" width="180" height="60" rx="10" fill="#44475a" stroke="#ff5555" stroke-width="1.5" />
<text x="130" y="264" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Audit log</text>
<text x="130" y="284" text-anchor="middle" fill="#bd93f9" font-size="11">every sensitive op + IP</text>
<rect x="40" y="320" width="180" height="60" rx="10" fill="#44475a" stroke="#ff5555" stroke-width="1.5" />
<text x="130" y="344" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">CSP + HSTS</text>
<text x="130" y="364" text-anchor="middle" fill="#bd93f9" font-size="11">nonce CSP · X-Frame-Options</text>
<rect x="40" y="400" width="180" height="60" rx="10" fill="#44475a" stroke="#ff5555" stroke-width="1.5" />
<text x="130" y="424" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">SSRF guard</text>
<text x="130" y="444" text-anchor="middle" fill="#bd93f9" font-size="11">outbound test endpoints</text>
<rect x="980" y="160" width="180" height="60" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1070" y="184" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">API tokens</text>
<text x="1070" y="204" text-anchor="middle" fill="#bd93f9" font-size="11">hlk_* Bearer · HMAC stored</text>
<rect x="980" y="240" width="180" height="60" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1070" y="264" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Refresh tokens</text>
<text x="1070" y="284" text-anchor="middle" fill="#bd93f9" font-size="11">24h access · rotated refresh</text>
<rect x="980" y="320" width="180" height="60" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1070" y="344" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Off-host backup</text>
<text x="1070" y="364" text-anchor="middle" fill="#bd93f9" font-size="11">AES-GCM · S3-compatible</text>
<rect x="980" y="400" width="180" height="60" rx="10" fill="#44475a" stroke="#bd93f9" stroke-width="1.5" />
<text x="1070" y="424" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Per-user scope</text>
<text x="1070" y="444" text-anchor="middle" fill="#bd93f9" font-size="11">no cross-account reads</text>
</g>
<!-- Arrows: side rails into perimeters -->
<g stroke="#ff5555" stroke-width="1.2" stroke-dasharray="3 3" fill="none" marker-end="url(#a5-r)">
<path d="M220 190 L 260 220" />
<path d="M220 270 L 320 320" />
<path d="M220 350 L 320 350" />
<path d="M220 430 L 320 410" />
</g>
<g stroke="#bd93f9" stroke-width="1.2" stroke-dasharray="3 3" fill="none" marker-end="url(#a5-pu)">
<path d="M980 190 L 880 410" />
<path d="M980 270 L 880 410" />
<path d="M980 350 L 780 280" />
<path d="M980 430 L 780 410" />
</g>
<!-- Footnote -->
<text x="600" y="585" fill="#6272a4" font-size="11" text-anchor="middle">No data leaves the host unless an integration explicitly asks for it. Bearer tokens never elevate to admin — admin actions require a cookie session.</text>
<defs>
<marker id="a5-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff5555" /></marker>
<marker id="a5-pu" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#bd93f9" /></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+27
View File
@@ -0,0 +1,27 @@
# Diagrams
Five SVG diagrams that anchor the architecture story. Hand-drawn-style,
Dracula palette, no external dependencies. Embed in README and doc-site
pages as `<img>` tags. Mirrored into `healthlog-docs/src/assets/diagrams/`
and `healthlog-landing/public/diagrams/` so each surface ships its own
copy.
| File | Story it tells |
| ---- | -------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
## Editing
Open in any text editor. Each file is hand-authored SVG ≤ 12 KB; the
viewport, the `defs` block of arrow markers, and the labelled groups are
all readable inline. Keep the Dracula palette (`#282a36` background,
`#f8f8f2` text, accents `#8be9fd / #ffb86c / #50fa7b / #bd93f9 /
#ff79c6 / #ff5555`) and the `viewBox="0 0 1200 620"` constraint so the
diagrams stay consistent across the three sites.
After editing, copy the file to the sister repos so all three surfaces
ship the updated version.