Commit Graph
6033 Commits
Author SHA1 Message Date
Marc-André Bombeck b04d07923d Merge test/guard-coverage-gaps 2026-09-03 03:23:38 +02:00
Marc-André Bombeck a675daf0c1 Merge fix/module-gate-and-webhook 2026-09-03 03:23:37 +02:00
Marc-André Bombeck 98324b0eff Merge origin/main 2026-09-03 03:23:08 +02:00
Marc-André Bombeck f6a45ca5c5 Merge pull request #901 from MBombeck/perf/dashboard-seq-scans
perf(dashboard): stop scanning the two largest tables on every load
2026-09-03 03:15:58 +02:00
Marc-André Bombeck 69baecbe70 Merge origin/main 2026-09-03 02:33:19 +02:00
Marc-André Bombeck ea49dcb96e Merge origin/main 2026-09-03 02:33:14 +02:00
Marc-André Bombeck db6c8b2f0a Merge remote-tracking branch 'origin/main' into test/guard-coverage-gaps 2026-09-03 02:32:45 +02:00
Marc-André Bombeck 6a733c9272 Merge pull request #905 from MBombeck/chore/advisory-floors-0903
chore(security): raise two advisory floors that went stale overnight
2026-09-03 02:32:34 +02:00
Marc-André Bombeck 1354e1d9b5 Raise two advisory floors that went stale overnight
Four advisories landed against the URI parser that arrives under ajv,
including the copy the connector SDK pulls, all of them host confusion
or request forgery through percent-encoded schemes, skipped IDN
canonicalisation and malformed authorities. The floor set yesterday was
one patch below the fix. A query-string parser needs 6.16.0 for an
array-limit bypass and a denial of service.

The audit reads no known vulnerabilities after the bump.
2026-09-03 02:07:38 +02:00
Marc-André Bombeck 6633f0fb19 fix(lint): cover the unguarded egress paths and aliased fetch
Two spellings the egress guard could not see, both probed.

src/proxy.ts, src/instrumentation.ts and src/cli/ sat outside the enforced
roots (src/lib/ and src/app/), and the companion api-fetch-required guards
the client surface, so it did not reach them either. A raw fetch dropped
into instrumentation.ts linted clean. All three are egress-free today, but
instrumentation is exactly where an exporter pointed at an operator-supplied
URL would land.

The callee match knew the identifier `fetch` and the globalThis / window /
self member forms only, so an aliased import escaped it in every file:
`import { fetch as f } from "undici"` then `f(url)` linted clean under
src/lib. That spelling is what src/lib/safe-fetch.ts itself writes. Local
names bound to an imported `fetch`, and namespace-imported `ns.fetch(…)`,
are now callees; the wrapper internals and test files stay exempt as
before, and the wrapper's own aliased import is pinned as a valid case.

Adds the rule's missing unit-test file. The header records what the
binding scan still cannot see: require(), dynamic import, a fetch passed
as a parameter, and egress that never spells fetch at all.
2026-09-03 01:50:25 +02:00
Marc-André Bombeck b956636d9a docs: record the faster dashboard open in the changelog 2026-09-03 01:47:52 +02:00
Marc-André Bombeck 83b6ce4b15 test: pin the dashboard summary read shapes against a plan regression
Neither read is a bug a behavioural test can see: both shapes return the
right rows, and only the plan differs. So the guards split by what each
can actually answer.

The unit guard asserts on the SQL the readers build, with comments
stripped so it matches the query text and never the prose describing it:
no DISTINCT ON on this path, a LATERAL driven from an unnested type list
with a LIMIT on the per-type branch, a bound type predicate on the rollup
read sitting alongside the user and granularity filters, and the route
still calling the shared readers rather than growing a second copy. Four
of its five cases fail against the pre-change SQL.

The integration companion runs the shipped readers and the pre-change SQL
side by side over one fixture on the real Postgres and requires identical
rows. The fixture carries the cases the rewrite could have broken: a type
whose only rows are tombstoned, a type with no rows, a type whose newest
row is a tombstone over a surviving one, and coarser rollup granularities
the DAY read must ignore. Dropping the soft-delete filter or shortening
the type list makes both cases fail.

Plan assertions are deliberately not in the integration file: a
testcontainer fixture is too small for the planner to choose the access
paths a real account produces, so an EXPLAIN there would pin nothing.
2026-09-03 01:47:52 +02:00
Marc-André Bombeck f2e98e3885 perf(dashboard): drive the per-type summary reads from the type list
Two reads behind the metric tiles scanned far more than the rows they
return, because PostgreSQL 16 has no index skip scan: a query that wants
one row per measurement type but never names a type cannot be served by
any index keyed on (user_id, type, ...).

The latest-reading read used DISTINCT ON (type), so it produced every
live row of the account in (type, measured_at DESC) order before keeping
one row per group. On a multi-year account that is a sequential scan plus
a sort that spills to disk once it outgrows work_mem. It is now a LATERAL
join over the type list, one lookup per type against the live-row partial
index from migration 0183, each stopping at the first row. CROSS JOIN
LATERAL is an inner join, so a type with no live reading drops out as
DISTINCT ON dropped it, and ORDER BY reproduces the same enum ordering.

The sparkline read filtered measurement_rollups on user_id and
granularity only, skipping the second column of the table's key, so on a
single-account host neither the primary key nor the user/type/
granularity/bucket index could drive it and every stored granularity got
scanned. It now names the full canonical enum, which filters nothing out
and restores the equality the key needs; the existing index serves it.

Measured on a fixture matching a real account (454k measurements, 79%
tombstoned, 75.5k rollup rows): the first read goes from a seq scan plus
a 3.2 MB external-merge sort at 112 ms to a nested loop at 0.7 ms, and
the second from a seq scan discarding 72k rows at 6.9 ms to a bitmap
index scan at 1.6 ms. Same rows, same order, same types.

Both reads move out of the route into src/lib/dashboard/summary-reads.ts
so the integration suite can exercise the SQL itself rather than a copy
of it, the same seam buildMedsTodayBlock already uses. No schema change:
adding an index on (user_id, granularity, bucket_start) would have landed
in the same range while leaving the existing index unused and adding a
fourth write target to a table the rollup workers upsert into constantly.
2026-09-03 01:47:41 +02:00
Marc-André Bombeck 1f5115fbf3 fix(lint): see positional query keys and the app tree
Two holes, both proven with a probe that produced zero errors.

The rule matched the object properties `queryKey:` and `mutationKey:` and
nothing else, so the positional form was never seen —
`setQueryData(["bare"], 1)` seeds the same cache cell a `queryKey:` reads,
and `getQueryData` / `getQueryState` / the four defaults setters read and
write it the same way. Those seven methods are checked at their first
argument now, whether the call is bare, a member, or a computed member.

The enforced roots were components, hooks, query modules, and files
carrying their own "use client", which left src/app out. That is where the
server prefetch pages build the keys the client later reads, and they
carry no directive, so a server key diverging from the client's was
unguarded. src/app is a root now.

No violation surfaces on the current tree. The rule header records what it
still cannot see: a key assembled into a variable before the call site,
and the method-name match, which neither follows a project helper nor
knows an unrelated object with the same method name.
2026-09-03 01:46:29 +02:00
Marc-André Bombeck 3989d9e9f9 test(modules): derive the route-gate inventory from the module registry
The inventory walked a flat, hand-written array of route trees, so its
coverage grew only when somebody remembered to grow it. It had stopped
growing: the coach, environment, mental-health, inbound-document and MCP
trees were all outside it, and so was the doctor-report export route the
header already named as a doctorReport surface. Every route in the first
four turned out to be gated, so nothing leaked — but that was the authors'
care rather than the inventory's, and a green run claimed more than it
covered.

The trees are declared per module key as an exhaustive Record<ModuleKey, …>:
a new registry key does not compile without an entry, one test asserts every
key is declared, another asserts every declared tree exists, and a third
discovers an API directory named after a module key on disk and fails when
it is not declared. An empty array is now a claim with a reason beside it
(glucose and recovery ride the measurement engine), not a silence.

Walking the newly covered trees classifies every route: coach, environment,
mental-health, the document vault and /api/export/health-record all gate
directly. The seven /api/mcp routes are the credential surface behind the
remote endpoint and go on the exempt list with the reason written out — the
endpoint itself lives outside /api and carries the gate, so a credential
minted while the module is off is inert. That refusal is added to the
behavioural list so deleting its proof fails here.

/api/export/health-record joins /api/fhir as a key-pinned tree: both serve
the whole-record aggregate, so doctorReport is the only correct key.
2026-09-03 01:46:01 +02:00
Marc-André Bombeck 20812b54f5 fix(webhooks): rate-limit every verb that checks the shared secret
The Withings entrypoints, the Telegram bot webhook and the Coolify deploy
hook limited POST and left the verification verbs unlimited. GET and HEAD
compare the same secret and answer 200 against 401, which is an oracle for
guessing it and a free load channel besides; timingSafeEqual protects the
timing channel, not the status code. The project's rule is that the
per-source limit runs before secret verification, and it was being applied
per handler rather than per file.

Each verification verb now runs the same limit its POST sibling used, on the
same bucket. Telegram and the deploy hook grow the small local helper the
Withings routes already share. HEAD re-shapes the limiter's JSON envelope
into a bodyless 429 and keeps the rate-limit headers.

The new guard walks every webhook route from the filesystem, resolves which
local helpers reach a comparison and which reach the limiter, and fails when
an exported verb reaches a comparison without the limiter in front of it. It
asserts a non-zero match count so an empty match set cannot read as green.
WHOOP already passed: it exports POST only.
2026-09-03 01:42:56 +02:00
Marc-André Bombeck 84437d5f95 test: read every spelling of a dependency override pin
The lockstep guard compared only the pins both matchers happened to
recognise, and neither matcher said so when it recognised fewer.

Workspace side: the key had to be quoted. Every entry in the file carries
an `@<selector>` suffix and therefore needs quoting, so the matcher looked
complete — but `mysql2: "^3.22.0"` is a valid pnpm override with no
selector and no quotes, and it parsed to nothing. Unquoting that key and
pointing the Dockerfile at `^1.0.0` left all six tests green.

Dockerfile side: only `npm pkg set 'overrides.X=Y'` was recognised, so
re-quoting the same line as `npm pkg set overrides.X=Y` removed the pin
from the comparison without changing what the image installs.

Both sides now parse line by line: quoted or bare keys and values on the
workspace side, any shell quoting of the same assignment on the Dockerfile
side, plus a raw JSON `"overrides"` block. What neither table can read is
collected and asserted empty, so a future spelling fails loudly instead of
shrinking the compared set in silence.
2026-09-03 01:42:16 +02:00
Marc-André Bombeck 214b268b41 Merge pull request #898 from MBombeck/release/v1.38.4
chore(release): v1.38.4 — the shared key finds its provider
v1.38.4
2026-09-02 17:24:10 +02:00
Marc-André Bombeck 253bd3175a chore(release): v1.38.4 — the shared key finds its provider
The admin AI key now goes to the API its base URL names, seven code
scanning findings are closed at their source, and the guard that was
meant to hold the image's two dependency pins in step can see scoped
packages for the first time. Two accessibility markers and one audit
read no longer race their own page.
2026-09-02 17:01:06 +02:00
Marc-André Bombeck a0f2e0d260 Merge pull request #894 from MBombeck/chore/scanning-sweep
chore(security): close seven scanning alerts and the guard that hid one
2026-09-02 17:00:26 +02:00
Marc-André Bombeck 0635db898a Merge origin/main into chore/scanning-sweep 2026-09-02 16:42:17 +02:00
Marc-André Bombeck 15ded13b79 Merge pull request #896 from MBombeck/test/a11y-visible-locators
test(a11y): match only the visible copy of a painted marker
2026-09-02 16:41:57 +02:00
Marc-André Bombeck 0e605b0698 Merge pull request #897 from MBombeck/fix/admin-provider-host
fix(ai): send the admin key to the provider its base URL names
2026-09-02 16:34:56 +02:00
Marc-André Bombeck eb53e9aaf4 Merge remote-tracking branch 'origin/main' into fix/admin-provider-host 2026-09-02 16:17:55 +02:00
Marc-André Bombeck b34e911c8e docs(admin): say which providers the server-key base URL accepts
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
2026-09-02 16:17:17 +02:00
Marc-André Bombeck f4c90dc134 fix(ai): pick the admin client from the configured base URL
The operator's global provider always built an OpenAI client, so an admin
who saved an Anthropic key and https://api.anthropic.com/v1 (a host the
settings allowlist accepts) had every AI call fail: an OpenAI-shaped body
posted at Anthropic's Messages API, with nothing in the error pointing at
the mismatch.

Route by hostname instead. api.anthropic.com and any subdomain of
anthropic.com get the same AnthropicClient the per-user ANTHROPIC arm
builds, with the same Claude model default; every other host keeps the
OpenAI client, which is what an OpenAI-compatible endpoint needs. The
match parses the URL rather than searching the string, so a lookalike
host cannot claim the Anthropic wire, and an unparseable value keeps the
OpenAI path it had before.

The chain tag stays admin-openai: it names the slot, not the wire, and it
is persisted in user chains and pinned by the OpenAPI enum. The wire is
already visible in the instance type the wide events read.
2026-09-02 16:17:10 +02:00
Marc-André Bombeck a9055c96a4 Merge pull request #895 from MBombeck/docs/readme-thanks
docs: say thank you where people can see it
2026-09-02 16:14:57 +02:00
Marc-André Bombeck f2c7ed38a4 test(a11y): match only the visible copy of a painted marker
The login case failed with a strict-mode violation: the marker it waits
for resolved to two elements, one of them hidden. Two copies of a page
coexist for a moment during a route transition, so a bare attribute
locator can see both, and Playwright refuses an ambiguous match rather
than picking one. The run had passed on the same page code minutes
earlier, which is what a race looks like.

Four locators in this file already carry the :visible filter for that
reason. Seven did not, and had no .first() either, so every one of them
was exposed to the same failure on a slow enough runner. They carry it
now, which leaves the assertion aimed at the copy a reader would see.
2026-09-02 16:07:08 +02:00
Marc-André Bombeck 915d5dea3c Say thank you where people can see it
The contributor graph has carried every outside author correctly, because
fork pull requests are merged as merge commits and the authorship
survives onto main. But nobody reads a graph tab. The front page now
names the practice and renders the graph inline, so a first-time
contributor sees their own avatar on the page they landed on.

The avatar strip comes from a third-party renderer, the same class of
service as the four shields badges already at the top, and GitHub proxies
it so no visitor address reaches that host. It is generated rather than
hand-written on purpose: a hand-kept list is the kind of thing that goes
stale quietly.
2026-09-02 15:56:15 +02:00
Marc-André Bombeck 6cac31403b docs(changelog): record the dependency floors and the token scope 2026-09-02 15:47:14 +02:00
Marc-André Bombeck c5fc707e59 ci: scope the dependabot auto-merge write token to its job
The workflow granted contents: write and pull-requests: write at the top
level, so every step in the run held them, including the checkout and
the dependency install that execute code from the pull request being
merged. Scorecard flags the shape, and the shape is the problem: a
compromised transitive in a Dependabot branch would run with a token
that can write to the repository.

The workflow default is contents: read now, and the two write scopes sit
on the auto-merge job, which is the only job there is and the only place
they are spent. gh pr merge --auto needs contents: write to enable
merge-on-green and pull-requests: write to act on the pull request; the
major-bump comment needs pull-requests: write. The metadata fetch and
the test run need neither. Nothing about which updates merge
automatically changes.
2026-09-02 15:47:14 +02:00
Marc-André Bombeck c9f8aee476 build(docker): mirror the hono and valibot pins into /opt/prisma-cli
The image carries two installs. pnpm builds the app tree from
pnpm-workspace.yaml; npm builds /opt/prisma-cli and reads only its own
overrides field. The scan reported @hono/node-server@1.19.11
(GHSA-frvp-7c67-39w9, CVE-2026-39406) and valibot@1.2.0
(CVE-2026-59952) under that path while both packages were pinned and
clean in the app tree. Same shape as the deepmerge-ts finding, same
cause.

Rebuilt this exact install both ways to confirm rather than assume.
Without the two fields npm resolves @hono/node-server 1.19.11 and
valibot 1.2.0, which are the two versions the scan named. With them it
resolves 1.19.17 and 1.4.2, and prisma --version still reports 7.8.0.
Both arrive through prisma -> @prisma/dev.

The lockstep guard could not have caught this. Its workspace matcher
started a package name at [^"@], so every scoped key was invisible to
it: @hono/node-server was in the workspace file and absent from the
Dockerfile, the shared set never contained it, and the guard stayed
green. Scoped keys parse now, a test pins the reduction itself so the
blind spot cannot return quietly, and a second test asserts that the
three packages the image scan has actually flagged under /opt/prisma-cli
keep their npm-side mirror. Both fail when the pin is removed or moved
out of step.
2026-09-02 15:47:13 +02:00
Marc-André Bombeck f62cd59789 chore(deps): raise the stale advisory floors and pin valibot
Three overrides had drifted behind the advisories they were written for,
and the scan found the packages at versions the pins were supposed to
exclude.

dompurify moves from <=3.4.10 to <3.4.13. GHSA-55q2-fjhq-7xh7 is fixed
in 3.4.13 and the tree carried 3.4.12. The copy is jspdf's optional HTML
renderer, an optionalDependency at ^3.3.1, so the raise stays inside the
range jspdf declares.

postcss moves from <8.5.10 to <8.5.23 for CVE-2026-69153. next declares
an exact postcss@8.4.31, so nothing but this bound moves it; the build
has run on the 8.5 line since the first bump and stays there.

valibot gets a floor it never had, <1.4.2, for CVE-2026-59952. Two
copies arrive and neither is ours to call: an optional peer of
@hookform/resolvers, which this app does not use because it validates
with zod, and prisma's local dev server. 1.4.2 is the same major, so no
consumer is moved onto a new one.

@hono/node-server moves from <1.19.13 to <1.19.15 for
GHSA-frvp-7c67-39w9. The app tree already resolved 1.19.17, so this
changes nothing here; it exists so the bound states the real floor and
so the Dockerfile mirror has something correct to match.

The floor test in ci-determinism asserted the exact key and replacement
string, which meant every legitimate raise failed it while a floor gone
stale against a newer advisory passed it unchanged. It now reduces each
key to a package name and asserts the pin is at or above the fixed
version, with a case proving the comparison can return false.
2026-09-02 15:46:55 +02:00
Marc-André Bombeck c2ea07b0bc Merge pull request #891 from MBombeck/test/sharing-fence-flake
test(sharing): wait for the refusal audit row instead of guessing
2026-09-02 15:25:15 +02:00
Marc-André Bombeck 1de8634625 Merge pull request #890 from MBombeck/release/v1.38.3
chore(release): v1.38.3 — delete from the phone, name every source
v1.38.3
2026-09-02 15:24:46 +02:00
Marc-André Bombeck 9d77120136 test(sharing): wait for the refusal audit row instead of guessing
The scope-fence test asserts that a refused section leaves a
sharing.access.denied audit row naming the reason. That row is written
without being awaited, on purpose, so the audit never delays the 403 the
caller is waiting on, and it lands some time after the response returns.
The test bridged that gap with a fixed 150 ms sleep, which a slow runner
outran: the read found an empty table and the assertion reported no
refusal, which reads exactly like the section having reached the wire,
the one thing this file exists to catch.

The positive case now polls for the row and fails with a message naming
the caller when none arrives within five seconds. The absence case keeps
a fixed wait, longer than before, because an absence claim is only worth
something if it waited. Proven by dropping the audit write behind an env
flag: eleven cases fail with the new message, and pass again with the
write restored.
2026-09-02 13:01:23 +02:00
Marc-André Bombeck 397baac818 chore(release): v1.38.3 — delete from the phone, name every source
The account-deletion fix landed after v1.38.2 was tagged, so its entry
moves out of that section and into this one where it belongs. Also in
this release: every measurement source carries a display name in all
seven languages, with a test that reads the enums out of the schema.
2026-09-02 12:29:09 +02:00
Marc-André Bombeck 71a6dd0e2a Merge pull request #889 from MBombeck/fix/source-priority-labels
fix(i18n): name every measurement source instead of showing its enum
2026-09-02 12:28:06 +02:00
Marc-André Bombeck 4918e9fd63 Merge remote-tracking branch 'origin/main' into fix/source-priority-labels 2026-09-02 12:09:58 +02:00
Marc-André Bombeck 7ee8bc8134 Fail the suite when a source enum member has no label
The four raw values in the sources ladder shipped because nothing checks
the catalogues against the enum they render. The new guard reads
MeasurementSource and IntakeSource out of prisma/schema.prisma, not out
of a copy, and fails naming every member no catalogue labels; it also
asserts the key each entry names resolves in all seven bundles, since a
key that resolves nowhere renders no better than the enum value. Run red
against the tree before the labels landed, it reported seven unlabelled
measurement sources per catalogue and one unlabelled intake source.

The schema reader now carries each enum's declared members so the guard
can read them.
2026-09-02 12:08:38 +02:00
Marc-André Bombeck 0548c0265e Label every ingest source instead of leaking its enum value
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.

The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
2026-09-02 12:08:31 +02:00
Marc-André Bombeck 53689ee99e Merge pull request #888 from MBombeck/fix/account-delete-bearer-mfa
fix(auth): let the app delete an account that has a second factor
2026-09-02 12:08:20 +02:00
Marc-André Bombeck 101c11f85e Merge remote-tracking branch 'origin/main' into fix/account-delete-bearer-mfa 2026-09-02 11:52:34 +02:00
Marc-André Bombeck df5dd1c160 docs: publish the Bearer arm on the two erasure routes
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.

The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.

Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
2026-09-02 11:51:19 +02:00
Marc-André Bombeck 5856b2a547 auth: let a token prove a second factor for erasure
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.

Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.

The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.

The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
2026-09-02 11:51:09 +02:00
Marc-André Bombeck 72117104d5 Merge pull request #887 from MBombeck/release/v1.38.2
chore(release): v1.38.2 — Anthropic on current models, briefing in your language
v1.38.2
2026-09-02 11:33:55 +02:00
Marc-André Bombeck 56ded00395 chore(release): v1.38.2 — Anthropic on current models, briefing in your language
No assistant prefill on Anthropic JSON calls, Claude 5 and Fable count as
vision-capable, the briefing cache carries its language and is never
served to a reader of another one, and job paths resolve a missing
account language to the instance default.
2026-09-02 11:10:35 +02:00
Marc-André Bombeck ad0a055fdc Merge pull request #886 from MBombeck/fix/briefing-locale
fix: never serve the briefing in another language than the reader's
2026-09-02 11:09:23 +02:00
Marc-André Bombeck a55ecc5a48 Merge origin/main into fix/briefing-locale 2026-09-02 10:51:56 +02:00
Marc-André Bombeck 718cc3568f Merge pull request #885 from MBombeck/fix/anthropic-no-prefill
fix(ai): no assistant prefill on Anthropic, Claude 5 counts as vision-capable
2026-09-02 10:51:25 +02:00