mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end: Every failure path in the nightly pregenerate + the comprehensive generator now annotates a queryable cause (several classes — features-error, payload-too-large, aborted, generator-failed — previously bumped a counter with zero trace). A failed nightly warm enqueues one bounded retry ~45 minutes later (singleton per user, existing budgets apply), so a transient provider hiccup heals before morning. A cached payload that carries NO briefing no longer satisfies the 24h POST short-circuit nor the unchanged-content hash gate — a once-stripped briefing could previously block its own regeneration indefinitely. The card surfaces the omission reason persistently (grounding strip, generation failure, not attempted) with a forcing retry. Documentation corrected against code truth: Withings credentials are per-user (the documented env path never existed; the dead example entries and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually whitelisted in compose — stock deploys could never deliver it); the classic Fitbit portal no longer accepts new registrations; the backup-restore example no longer mints a fresh encryption key and uses pnpm dlx tsx; the TLS guide describes the real client model (two CA-level SPKI pins, any-match, leaf renewals need no build); scaling/admin references fixed; the operator-shared subscription provider is documented.
This commit is contained in:
1 parent
14a517387e
commit
a4feabec81
26 files changed
+1190
-148
No files matched your search
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.29
|
||||
version: 1.28.30
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
|
||||
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
|
||||
their own key fall back to the admin key. This is the last entry
|
||||
in the default provider chain so it acts as a safety net rather
|
||||
than the default route.
|
||||
than the default route. Since v1.28.14 the operator can also share
|
||||
a subscription-based account that users opt into — see
|
||||
[Admin-shared subscription access](#admin-shared-subscription-access).
|
||||
|
||||
Single-user instances usually pick one mode and stop there.
|
||||
Multi-user instances tend to run BYOK with the admin-shared key as a
|
||||
@@ -209,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
|
||||
audit the request shape; users who just want the integration to
|
||||
work do not need to read it.
|
||||
|
||||
## Admin-shared subscription access
|
||||
|
||||
Besides the shared API key, the operator can connect a single
|
||||
subscription-based AI account for the whole instance — an
|
||||
operator-shared subscription provider that users without any
|
||||
personal AI setup can opt into.
|
||||
|
||||
- **Admin side.** Under **Admin → AI** the operator runs the same
|
||||
device-code OAuth flow as the per-user connection
|
||||
(`POST /api/admin/central-codex/device-start` +
|
||||
`device-poll`). The tokens land AES-256-GCM-encrypted in the
|
||||
`AppSettings` singleton and are never returned by any endpoint —
|
||||
status and disconnect go through `GET` / `DELETE
|
||||
/api/admin/central-codex`, which sit behind the cookie-only
|
||||
`requireAdmin()` boundary.
|
||||
- **User side.** Each user opts in individually in `/settings/ai`
|
||||
(`PATCH /api/auth/me/use-central-codex`, body
|
||||
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
|
||||
- **Chain semantics.** The shared connection is the internal
|
||||
`admin-codex` chain entry. It is never part of the persisted chain
|
||||
and never offered in the chain editor — it is appended **last** to
|
||||
the resolved chain, and only when the user opted in AND the
|
||||
operator has connected the account
|
||||
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
|
||||
resolves to nothing.
|
||||
- **Cost.** Generations through the shared connection run under the
|
||||
operator's account, so usage and rate limits land on the operator,
|
||||
not the user.
|
||||
|
||||
The settings UI states this plainly: the shared access is a single
|
||||
signed-in AI account the operator connected for everyone on the
|
||||
server — not the user's own key. Users who want their data to reach
|
||||
only a provider under their own contract should configure a personal
|
||||
provider instead; their own entries always resolve first.
|
||||
|
||||
## Privacy stance
|
||||
|
||||
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
|
||||
|
||||
+20
-17
@@ -5,10 +5,10 @@
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
|
||||
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
|
||||
operator on their behalf) registers their own Fitbit app and pastes the client
|
||||
id/secret into Settings. The internal integration key is `fitbit`.
|
||||
operator on their behalf) supplies their own Fitbit app's client id/secret in
|
||||
Settings. The internal integration key is `fitbit`.
|
||||
|
||||
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
|
||||
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
|
||||
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
|
||||
> Web API in **September 2026** and moving to the Google Health API behind Google
|
||||
> sign-in. That replacement currently requires Google Restricted-scope brand
|
||||
> verification plus an annual third-party CASA security assessment, which does
|
||||
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
|
||||
> sunset is still being worked out. Until then the setup below is the supported
|
||||
> way to connect Fitbit.
|
||||
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
|
||||
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
|
||||
> disabled for new applications, and the classic Fitbit Web API sunsets in
|
||||
> **September 2026**. If you already hold a registered Fitbit app, it keeps
|
||||
> working until the sunset and the setup below still applies. If you are
|
||||
> connecting Fitbit data for the first time, use the
|
||||
> [Google Health integration](google-health.md) instead — Fitbit devices sync
|
||||
> into Google's health stack, which HealthLog reads directly.
|
||||
|
||||
## 1. Register a Fitbit developer app
|
||||
## 1. Use an existing Fitbit developer app
|
||||
|
||||
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
|
||||
2. **OAuth 2.0 Application Type:**
|
||||
Registration of new apps is no longer possible (see the note above). For an
|
||||
app you registered before the shutdown, these are the settings that matter:
|
||||
|
||||
1. **OAuth 2.0 Application Type:**
|
||||
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
|
||||
and gets intraday (per-minute) heart-rate/steps automatically. This is the
|
||||
right choice for a single-person self-host.
|
||||
- **Server** (or **Client**) — needed when the instance connects **other
|
||||
people's** accounts. Intraday/heart-rate access is then granted case by
|
||||
case via Fitbit's Intraday Data Access request form.
|
||||
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
|
||||
the redirect-URI note below). One entry per environment you connect from.
|
||||
4. **Scopes:** tick the ones HealthLog requests (next section).
|
||||
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
|
||||
— keep the secret out of any chat log.
|
||||
3. **Scopes:** the ones HealthLog requests (next section).
|
||||
4. The app's settings page shows the **Client ID** (short, numeric) and the
|
||||
**Client Secret** — keep the secret out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
|
||||
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
|
||||
2. The user signs in on WHOOP and grants the requested scopes.
|
||||
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
|
||||
4. HealthLog exchanges the code for an access + refresh token, stores
|
||||
them encrypted, registers the webhook, and triggers an initial
|
||||
sync of recent history.
|
||||
them encrypted, and triggers an initial sync of recent history.
|
||||
(The webhook is **not** registered per user — it is configured once
|
||||
at app level in the WHOOP developer console; see the env-var
|
||||
section below.)
|
||||
5. Subsequent syncs run on every webhook delivery plus a safety-net
|
||||
cron pull.
|
||||
|
||||
|
||||
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
|
||||
## 2. Wire the credentials into HealthLog
|
||||
## 2. Wire the credentials in Settings → Integrations
|
||||
|
||||
Paste the client ID and secret into `.env`:
|
||||
Withings credentials are stored per user, not in `.env`. Sign in as
|
||||
the target user, open **Settings → Integrations → Withings**, and
|
||||
paste the client ID and secret. They are encrypted AES-256-GCM at
|
||||
rest (`src/lib/withings/credentials.ts`). On a shared instance every
|
||||
user brings their own Withings app credentials; there is no
|
||||
server-level client-id/secret fallback.
|
||||
|
||||
Two server-level env vars remain:
|
||||
|
||||
```env
|
||||
WITHINGS_CLIENT_ID="your-client-id"
|
||||
WITHINGS_CLIENT_SECRET="your-client-secret"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
```
|
||||
|
||||
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
|
||||
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
|
||||
keeps it indistinguishable from random. Restart the `app` container
|
||||
after saving.
|
||||
|
||||
Per-user override is also possible: the Settings UI accepts a
|
||||
client-ID/secret pair scoped to one user, useful if you want a
|
||||
shared instance where every user brings their own Withings app
|
||||
credentials. Server-level env vars cover the default case.
|
||||
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
|
||||
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
|
||||
only when the OAuth callback must differ from that default.
|
||||
|
||||
## 3. Set the webhook URL
|
||||
|
||||
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
|
||||
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With env vars in place and the app restarted, sign in as the target
|
||||
user and open `/settings/integrations/withings`. Click **Connect
|
||||
Withings**. The flow:
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
|
||||
### The Connect button errors out
|
||||
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Withings credentials not configured`** — the signed-in user has
|
||||
not saved a client ID + secret under **Settings → Integrations →
|
||||
Withings**. Credentials live per user in the database, not in
|
||||
`.env` — paste the pair from the developer portal and retry.
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
|
||||
@@ -68,20 +68,26 @@ The credentials are never returned.
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
and run the restore script with the same backup credentials and
|
||||
encryption key the backup was written under — a freshly generated
|
||||
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||
pnpm tsx scripts/restore-backup.ts \
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
|
||||
pnpm dlx tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The production standalone image strips `tsx`, so a bare
|
||||
`pnpm tsx scripts/...` fails inside the container — always invoke
|
||||
one-shot scripts via `pnpm dlx tsx`.
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
|
||||
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
3. **Run the rotation script** to re-encrypt every existing encrypted column
|
||||
under the new active key:
|
||||
```
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
@@ -69,7 +69,7 @@ script reports zero `v2.`-prefixed rows remaining.
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
|
||||
+65
-48
@@ -1,26 +1,34 @@
|
||||
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
|
||||
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
|
||||
|
||||
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
|
||||
deliberate hardening choice on the client side, but it couples the client to
|
||||
the exact leaf keypair the server serves: when that leaf rotates, a client
|
||||
that was only ever shipped the old pin refuses to connect. This page explains
|
||||
the coupling, the server-side alarm that watches for a rotation, and the
|
||||
operator / release-owner steps when it fires.
|
||||
The native client SPKI-pins the server's TLS chain at the **CA level**. It
|
||||
ships a small pin set — currently the issuing intermediate plus the root —
|
||||
and accepts a connection only when the served chain passes baseline X.509
|
||||
evaluation AND at least one certificate anywhere in the chain matches a pin
|
||||
(any-match). The leaf is deliberately **not** pinned, so routine leaf
|
||||
renewals are a client no-op. The outage risk is a **chain change**: the
|
||||
served chain stops terminating in the pinned CA keys — the server moves to a
|
||||
different CA, or the CA rotates its intermediate / root keys. This page
|
||||
explains the coupling, the server-side alarm that watches the served leaf,
|
||||
and the operator / release-owner steps when it fires.
|
||||
|
||||
## The coupling
|
||||
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
|
||||
certificate's public key, not of the whole certificate. The native client
|
||||
carries a set of these pins and rejects any TLS connection whose leaf public
|
||||
key is not in the set.
|
||||
carries a set of these pins (build-time config, ≥ 2 well-formed pins
|
||||
enforced for release builds) and rejects any TLS connection whose validated
|
||||
chain contains no pinned key. Hosts outside the client's pinned-host list
|
||||
fall back to plain system-trust validation, which is the documented model
|
||||
for self-hosted instances that ship their own chain.
|
||||
|
||||
The app host's certificate is issued by Google Trust Services (GTS) and
|
||||
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
|
||||
so the SPKI pin changes on every renewal. Intermediate and root rolls have
|
||||
the same effect on the leaf chain. Because the client pins the leaf, every
|
||||
such change is a hard outage for any client that hasn't been shipped the new
|
||||
pin — and there is otherwise no server-side signal that a client is pinning
|
||||
the served leaf at all.
|
||||
The app host's certificate auto-renews roughly every 90 days and each
|
||||
renewal mints a new leaf keypair. Because the client pins the CA keys rather
|
||||
than the leaf, those renewals pass without a client build. What DOES break
|
||||
every pinned client is a chain that no longer terminates in a pinned key —
|
||||
and there is no server-side signal that clients are pinning the served chain
|
||||
at all. The alarm below exists to catch exactly that early: it watches the
|
||||
served **leaf**, the most frequently rotating element, as the canary for any
|
||||
chain movement.
|
||||
|
||||
## The alarm
|
||||
|
||||
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
|
||||
|
||||
### Baseline source: an env var, not auto-learning
|
||||
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
|
||||
an env var on purpose, **not** a persisted "last-seen" row:
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
|
||||
leaf** pins. It is the operator's explicit record of what the server is
|
||||
expected to serve — distinct from the client's CA-level pin set, which lives
|
||||
in the client build config. The baseline is an env var on purpose, **not** a
|
||||
persisted "last-seen" row:
|
||||
|
||||
- The pinned client only trusts the pins it was shipped, so the operator must
|
||||
derive the client's pin set from a single explicit source of truth. The env
|
||||
var **is** that source — the same value goes into the client's pin set and
|
||||
into `TLS_LEAF_SPKI_PINS`.
|
||||
- An explicit baseline forces the operator to acknowledge every leaf
|
||||
rotation and check the new chain against the client's pinned CA keys
|
||||
before the alarm goes quiet.
|
||||
- A persisted last-seen baseline would silently adopt the first rotated pin
|
||||
and suppress the very alarm the pinned client needs.
|
||||
- When the env var is unset, the monitor fails **loud, not open**: it logs
|
||||
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
|
||||
whether it is set. It is optional: without it the monitor still runs and logs
|
||||
the served pin, but cannot alarm on a change.
|
||||
|
||||
## When the alarm fires — re-pin procedure
|
||||
## When the alarm fires
|
||||
|
||||
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
|
||||
row, the served leaf has rotated. The pinned client will stop connecting once
|
||||
its shipped pin no longer matches a served leaf. Act inside the certificate's
|
||||
remaining validity, with margin for app review and client roll-out — target
|
||||
**≥ 11 days before the old pin's certificate expires** (the alert body and
|
||||
audit row both carry the new leaf's `validTo`).
|
||||
row, the served leaf has rotated. First determine which of two cases you are
|
||||
in:
|
||||
|
||||
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
|
||||
should equal the `servedPin` in the alert.
|
||||
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
|
||||
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
|
||||
comma-separated pair, e.g.
|
||||
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
|
||||
on the old build keep working through the cutover and the server-side
|
||||
alarm goes quiet because the served pin is now in the known set. Re-deploy
|
||||
the server so the new env value takes effect.
|
||||
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
|
||||
pipeline above, or the alert payload). If the issuing intermediate and
|
||||
root are unchanged, the client's CA-level pins still match and no client
|
||||
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
|
||||
re-deploy so the alarm goes quiet. No client build.
|
||||
2. **Chain change.** The new leaf chains through a different intermediate /
|
||||
root (CA switch, or the CA rotated its keys). Every pinned client will
|
||||
stop connecting once the old chain is no longer served — run the re-pin
|
||||
procedure below inside the old chain's remaining validity, with margin
|
||||
for app review and client roll-out (the alert body and audit row carry
|
||||
the new leaf's `validTo`).
|
||||
|
||||
### Re-pin procedure (chain change)
|
||||
|
||||
1. **Extract the new chain's CA pins** (intermediate + root) with the
|
||||
`openssl` pipeline / the client repo's SPKI-extraction script.
|
||||
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
|
||||
the old ones (do not replace them yet). Clients on the old build keep
|
||||
working as long as the old chain is still served; the dual-pinned build
|
||||
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
|
||||
served leaf pin(s) so the server-side alarm reflects reality.
|
||||
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
|
||||
onward to release). Give it long enough to roll out to the installed base
|
||||
before the next renewal.
|
||||
4. **Retire the old pin** once the old leaf is no longer served and the
|
||||
dual-pinned client build has reached the installed base: drop the old pin
|
||||
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
|
||||
current leaf pin.
|
||||
onward to release). Give it long enough to roll out to the installed
|
||||
base before the old chain disappears.
|
||||
4. **Retire the old pins** once the old chain is no longer served and the
|
||||
dual-pinned client build has reached the installed base.
|
||||
|
||||
Treat the dual-pin window as standing practice: always ship the **next** pin
|
||||
before the current leaf rotates, so a renewal never catches a single-pinned
|
||||
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
|
||||
upcoming leaf ahead of the renewal turns every rotation into a no-op.
|
||||
CA-level pins move rarely — intermediates and roots carry multi-year
|
||||
lifetimes and rotations are announced well ahead. Track the pinned
|
||||
certificates' expiry dates and pre-stage the successor pins before the
|
||||
cutover, so a chain change never catches a single-chain client.
|
||||
|
||||
## Environment assumption
|
||||
|
||||
|
||||
@@ -24,15 +24,18 @@ deployed in three modes:
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
atomically via row-level locking, so it is safe to run multiple worker
|
||||
replicas — they will share the load.
|
||||
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
|
||||
running multiple worker replicas is **safe** — no job runs twice —
|
||||
but it doubles the DB load and muddies telemetry without adding
|
||||
throughput a personal instance needs (`src/lib/process-type.ts`).
|
||||
Run **one** worker container and scale the `web` containers instead.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/worker-status`.
|
||||
`/api/admin/status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
|
||||
Reference in new issue
Block a user